diff --git a/.github/workflows/wasm32.yml b/.github/workflows/wasm32.yml index 3da1ef4ca89a..721ded21b861 100644 --- a/.github/workflows/wasm32.yml +++ b/.github/workflows/wasm32.yml @@ -40,7 +40,7 @@ jobs: rm wasm32-wasi-libs.tar.gz - name: Build spec/wasm32_std_spec.cr - run: bin/crystal build spec/wasm32_std_spec.cr -o wasm32_std_spec.wasm --target wasm32-wasi -Duse_pcre + run: bin/crystal build spec/wasm32_std_spec.cr -o wasm32_std_spec.wasm --target wasm32-wasi -Duse_pcre -Dwithout_openssl env: CRYSTAL_LIBRARY_PATH: ${{ github.workspace }}/wasm32-wasi-libs diff --git a/spec/std/uuid_spec.cr b/spec/std/uuid_spec.cr index 0992c92095d1..12e497829b16 100644 --- a/spec/std/uuid_spec.cr +++ b/spec/std/uuid_spec.cr @@ -162,10 +162,30 @@ describe "UUID" do expect_raises(ArgumentError) { UUID.new "xyz:uuid:1ed1ee2f-ef9a-4f9c-9615-ab14d8ef2892" } end + describe "v1" do + it "returns true if UUID is v1, false otherwise" do + uuid = UUID.v1 + uuid.v1?.should eq(true) + uuid = UUID.v1(node_id: StaticArray(UInt8, 6).new { |i| (i*10).to_u8 }) + uuid.to_s[24..36].should eq("000a141e2832") + end + end + + describe "v2" do + it "returns true if UUID is v2, false otherwise" do + uuid = UUID.v2(UUID::Domain::Person, 42) + uuid.v2?.should eq(true) + uuid = UUID.v2(UUID::Domain::Person, 42, node_id: StaticArray(UInt8, 6).new { |i| (i*10).to_u8 }) + uuid.to_s[24..36].should eq("000a141e2832") + end + end + describe "v4?" do it "returns true if UUID is v4, false otherwise" do uuid = UUID.random uuid.v4?.should eq(true) + uuid = UUID.v4 + uuid.v4?.should eq(true) uuid = UUID.new("00000000-0000-0000-0000-000000000000", version: UUID::Version::V5) uuid.v4?.should eq(false) end @@ -175,8 +195,78 @@ describe "UUID" do it "returns true if UUID is v4, raises otherwise" do uuid = UUID.random uuid.v4!.should eq(true) + uuid = UUID.v4 + uuid.v4!.should eq(true) uuid = UUID.new("00000000-0000-0000-0000-000000000000", version: UUID::Version::V5) expect_raises(UUID::Error) { uuid.v4! } end end + + describe "v3" do + it "generates DNS based names correctly" do + data = "crystal-lang.org" + expected = "60a4b7b5-3333-3f1e-a2cd-30d8a2d0b83b" + UUID.v3(data, UUID::Namespace::DNS).to_s.should eq(expected) + UUID.v3_dns(data).to_s.should eq(expected) + UUID.v3_dns(data).v3?.should eq(true) + end + + it "generates URL based names correctly" do + data = "https://crystal-lang.org" + expected = "c25c7b79-5f5f-3844-98a4-2548f5d0e7f9" + UUID.v3(data, UUID::Namespace::URL).to_s.should eq(expected) + UUID.v3_url(data).to_s.should eq(expected) + UUID.v3_url(data).v3?.should eq(true) + end + + it "generates OID based names correctly" do + data = "1.3.6.1.4.1.343" + expected = "77bc1dc3-0a9f-3e7e-bfa5-3f611a660c80" + UUID.v3(data, UUID::Namespace::OID).to_s.should eq(expected) + UUID.v3_oid(data).to_s.should eq(expected) + UUID.v3_oid(data).v3?.should eq(true) + end + + it "generates X500 based names correctly" do + data = "cn=John Doe, ou=People, o=example, c=com" + expected = "fcab1a4c-fc81-3ebc-9874-9a8b931911d3" + UUID.v3(data, UUID::Namespace::X500).to_s.should eq(expected) + UUID.v3_x500(data).to_s.should eq(expected) + UUID.v3_x500(data).v3?.should eq(true) + end + end + + describe "v5" do + it "generates DNS based names correctly" do + data = "crystal-lang.org" + expected = "11caf27c-b803-5e62-9c4b-15332b04047e" + UUID.v5(data, UUID::Namespace::DNS).to_s.should eq(expected) + UUID.v5_dns(data).to_s.should eq(expected) + UUID.v5_dns(data).v5?.should eq(true) + end + + it "generates URL based names correctly" do + data = "https://crystal-lang.org" + expected = "29fec3f0-9ad0-5e8a-a42e-214ff695f50e" + UUID.v5(data, UUID::Namespace::URL).to_s.should eq(expected) + UUID.v5_url(data).to_s.should eq(expected) + UUID.v5_url(data).v5?.should eq(true) + end + + it "generates OID based names correctly" do + data = "1.3.6.1.4.1.343" + expected = "6aab0456-7392-582a-b92a-ba5a7096945d" + UUID.v5(data, UUID::Namespace::OID).to_s.should eq(expected) + UUID.v5_oid(data).to_s.should eq(expected) + UUID.v5_oid(data).v5?.should eq(true) + end + + it "generates X500 based names correctly" do + data = "cn=John Doe, ou=People, o=example, c=com" + expected = "bc10b2d9-f370-5c65-9561-5e3f6d9b236d" + UUID.v5(data, UUID::Namespace::X500).to_s.should eq(expected) + UUID.v5_x500(data).to_s.should eq(expected) + UUID.v5_x500(data).v5?.should eq(true) + end + end end diff --git a/src/uuid.cr b/src/uuid.cr index 7c8d44478f84..c7aaee0a605c 100644 --- a/src/uuid.cr +++ b/src/uuid.cr @@ -1,3 +1,14 @@ +require "time" +require "io" + +{% if flag?(:without_openssl) %} + require "crystal/digest/sha1" + require "crystal/digest/md5" +{% else %} + require "digest/sha1" + require "digest/md5" +{% end %} + # Represents a UUID (Universally Unique IDentifier). # # NOTE: To use `UUID`, you must explicitly import it with `require "uuid"` @@ -10,7 +21,7 @@ struct UUID Unknown # Reserved by the NCS for backward compatibility. NCS - # Reserved for RFC4122 Specification (default). + # Reserved for RFC 4122 Specification (default). RFC4122 # Reserved by Microsoft for backward compatibility. Microsoft @@ -22,7 +33,7 @@ struct UUID enum Version # Unknown version. Unknown = 0 - # Date-time and MAC address. + # Date-time and NodeID address. V1 = 1 # DCE security. V2 = 2 @@ -34,6 +45,31 @@ struct UUID V5 = 5 end + # A Domain represents a Version 2 domain (DCE security). + enum Domain + Person = 0 + Group = 1 + Org = 2 + end + + # MAC address to be used as NodeID. + alias MAC = UInt8[6] + + # Namespaces as defined per in the RFC 4122 Appendix C. + # + # They are used with the functions `v3` amd `v5` to generate + # a `UUID` based on a `name`. + module Namespace + # A UUID is generated using the provided `name`, which is assumed to be a fully qualified domain name. + DNS = UUID.new("6ba7b810-9dad-11d1-80b4-00c04fd430c8") + # A UUID is generated using the provided `name`, which is assumed to be a URL. + URL = UUID.new("6ba7b811-9dad-11d1-80b4-00c04fd430c8") + # A UUID is generated using the provided `name`, which is assumed to be an ISO OID. + OID = UUID.new("6ba7b812-9dad-11d1-80b4-00c04fd430c8") + # A UUID is generated using the provided `name`, which is assumed to be a X.500 DN in DER or a text output format. + X500 = UUID.new("6ba7b814-9dad-11d1-80b4-00c04fd430c8") + end + @bytes : StaticArray(UInt8, 16) # Generates UUID from *bytes*, applying *version* and *variant* to the UUID if @@ -176,6 +212,115 @@ struct UUID new(new_bytes, variant, version) end + # Generates RFC 4122 v1 UUID. + # + # The traditional method for generating a `node_id` involves using the machine’s MAC address. + # However, this approach is only effective if there is only one process running on the machine + # and if privacy is not a concern. In modern languages, the default is to prioritize security + # and privacy. Therefore, a pseudo-random `node_id` is generated as described in section 4.5 of + # the RFC. + # + # The sequence number `clock_seq` is used to generate the UUID. This number should be + # monotonically increasing, with only 14 bits of the clock sequence being used effectively. + # The clock sequence should be stored in a stable location, such as a file. If it is not + # stored, a random value is used by default. If not provided the current time milliseconds + # are used. In case the traditional MAC address based approach should be taken the + # `node_id` can be provided. Otherwise secure random is used. + def self.v1(*, clock_seq : UInt16? = nil, node_id : MAC? = nil) : self + tl = Time.local + now = (tl.to_unix_ns / 100).to_u64 + 122192928000000000 + seq = ((clock_seq || (tl.nanosecond/1000000).to_u16) & 0x3fff) | 0x8000 + + time_low = UInt32.new(now & 0xffffffff) + time_mid = UInt16.new((now >> 32) & 0xffff) + time_hi = UInt16.new((now >> 48) & 0x0fff) + time_hi |= 0x1000 # Version 1 + + uuid = uninitialized UInt8[16] + IO::ByteFormat::BigEndian.encode(time_low, uuid.to_slice[0..3]) + IO::ByteFormat::BigEndian.encode(time_mid, uuid.to_slice[4..5]) + IO::ByteFormat::BigEndian.encode(time_hi, uuid.to_slice[6..7]) + IO::ByteFormat::BigEndian.encode(seq, uuid.to_slice[8..9]) + + if node_id + 6.times do |i| + uuid.to_slice[10 + i] = node_id[i] + end + else + Random::Secure.random_bytes(uuid.to_slice[10..15]) + # set multicast bit as recommended per section 4.5 of the RFC 4122 spec + # to not conflict with real MAC addresses + uuid[10] |= 0x01_u8 + end + + new(uuid, version: UUID::Version::V1, variant: UUID::Variant::RFC4122) + end + + # Generates RFC 4122 v2 UUID. + # + # Version 2 UUIDs are generated using the current time, the local machine’s MAC address, + # and the local user or group ID. However, they are not widely used due to their limitations. + # For a given domain/id pair, the same token may be returned for a duration of up to 7 minutes + # and 10 seconds. + # + # The `id` depends on the `domain`, for the `Domain::Person` usually the local user id (uid) is + # used, for `Domain::Group` usually the local group id (gid) is used. In case the traditional + # MAC address based approach should be taken the `node_id` can be provided. Otherwise secure + # random is used. + def self.v2(domain : Domain, id : UInt32, node_id : MAC? = nil) : self + uuid = v1(node_id: node_id).bytes + uuid[6] = (uuid[6] & 0x0f) | 0x20 # Version 2 + uuid[9] = domain.to_u8 + IO::ByteFormat::BigEndian.encode(id, uuid.to_slice[0..3]) + new(uuid, version: UUID::Version::V2, variant: UUID::Variant::RFC4122) + end + + # Generates RFC 4122 v3 UUID using the `name` to generate the UUID, it can be a string of any size. + # The `namespace` specifies the type of the name, usually one of `Namespace`. + def self.v3(name : String, namespace : UUID) : self + klass = {% if flag?(:without_openssl) %}::Crystal::Digest::MD5{% else %}::Digest::MD5{% end %} + hash = klass.digest do |ctx| + ctx.update namespace.bytes + ctx.update name + end + new(hash[0...16], version: UUID::Version::V3, variant: UUID::Variant::RFC4122) + end + + # Generates RFC 4122 v4 UUID. + # + # It is strongly recommended to use a cryptographically random source for + # *random*, such as `Random::Secure`. + def self.v4(random r : Random = Random::Secure) : self + random(r) + end + + # Generates RFC 4122 v5 UUID using the `name` to generate the UUID, it can be a string of any size. + # The `namespace` specifies the type of the name, usually one of `Namespace`. + def self.v5(name : String, namespace : UUID) : self + klass = {% if flag?(:without_openssl) %}::Crystal::Digest::SHA1{% else %}::Digest::SHA1{% end %} + hash = klass.digest do |ctx| + ctx.update namespace.bytes + ctx.update name + end + new(hash[0...16], version: UUID::Version::V5, variant: UUID::Variant::RFC4122) + end + + {% for name in %w(DNS URL OID X500).map(&.id) %} + # Generates RFC 4122 v3 UUID with the `Namespace::{{ name }}`. + # + # * `name`: The name used to generate the UUID, it can be a string of any size. + def self.v3_{{ name.downcase }}(name : String) + v3(name, Namespace::{{ name }}) + end + + # Generates RFC 4122 v5 UUID with the `Namespace::{{ name }}`. + # + # * `name`: The name used to generate the UUID, it can be a string of any size. + def self.v5_{{ name.downcase }}(name : String) + v5(name, Namespace::{{ name }}) + end + {% end %} + # Generates an empty UUID. # # ```