oydid 0.6.1 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53df9e3939e5a1e5a6f1ccede456130a6160b08c3e89cb0dd4a05efbb8210ebc
4
- data.tar.gz: bce7ea71e480908e1565f2783a709a3a67e3f2b5e43492bc4a535f9750cb738d
3
+ metadata.gz: 7cd355cc4bdc04a3d149199467cbbe4263ace619676422c722485fc6d459c83b
4
+ data.tar.gz: 85f8930282e7cbee45997d65a5cca1be2bea78f853cce44152d6b93808e1a923
5
5
  SHA512:
6
- metadata.gz: b9483301abb9a88db165a3ad72024449c6731f042fee0b6480b23981999d658af14a87b76aa3170084a9de06e31b6f33ab10e1dc42c788e7a2b07b94b7641d69
7
- data.tar.gz: b14e69f7e18fa55169fedb57d5b5197f3332a4ccb0270dabee0ed5b7b0f9db005d4f81a8ab9e75e8dd4a44c1371e9e7c52be40032c342ae35c91c6e9eb3c4e4e
6
+ metadata.gz: e04e8cd58b7a29bb9e7dabffffcf613c9459c18ece1254b8cc1ad60b4d19d073276f18bd0f9c36b7cd6786340e1f81fb9be320f8964a182d5b861bfab9d5825f
7
+ data.tar.gz: 370d28f6c8614238b734a9a0dc8410de4007923c6ac7e464190ad36bd82bbba5f28def3e46d05c74d882b5ba5559f11cadf2a4a26b2ea0045ea87acd8a6503bb
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.6.1
1
+ 0.6.5
data/lib/oydid/basic.rb CHANGED
@@ -193,18 +193,31 @@ class Oydid
193
193
  end
194
194
  raw_key = raw_key.to_bytes
195
195
  when 'p256-priv'
196
- key = OpenSSL::PKey::EC.new('prime256v1')
197
196
  if input == ""
197
+ # no seed provided => generate a random key
198
198
  key = OpenSSL::PKey::EC.generate('prime256v1')
199
+ raw_key = key.private_key.to_s(2)
199
200
  else
200
- # input for p256-priv requires valid base64 encoded private key
201
+ # try to interpret input as a base64-encoded existing private key first;
202
+ # otherwise derive the key deterministically from input (seed/password),
203
+ # mirroring the ed25519-priv behaviour above
204
+ key = nil
201
205
  begin
202
- key = OpenSSL::PKey.read Base64.decode64(input)
206
+ candidate = OpenSSL::PKey.read(Base64.decode64(input))
207
+ key = candidate if candidate.is_a?(OpenSSL::PKey::EC)
203
208
  rescue
204
- return [nil, "invalid input"]
209
+ key = nil
210
+ end
211
+ if key.nil?
212
+ # deterministic derivation: hash seed to a scalar in [1, n-1]
213
+ order = OpenSSL::PKey::EC::Group.new('prime256v1').order
214
+ scalar = (OpenSSL::BN.new(RbNaCl::Hash.sha256(input), 2) % (order - OpenSSL::BN.new(1))) + OpenSSL::BN.new(1)
215
+ raw_key = scalar.to_s(2)
216
+ raw_key = ("\x00".b * (32 - raw_key.bytesize)) + raw_key if raw_key.bytesize < 32
217
+ else
218
+ raw_key = key.private_key.to_s(2)
205
219
  end
206
220
  end
207
- raw_key = key.private_key.to_s(2)
208
221
  else
209
222
  return [nil, "unsupported key codec"]
210
223
  end
@@ -284,7 +297,9 @@ class Oydid
284
297
  privateKey, msg = read_private_key(dsk.to_s, options)
285
298
  end
286
299
  else
287
- privateKey, msg = generate_private_key(pwd, 'ed25519-priv', options)
300
+ kt = options[:key_type].to_s
301
+ kt = 'ed25519' if kt == ''
302
+ privateKey, msg = generate_private_key(pwd, kt + '-priv', options)
288
303
  end
289
304
  else
290
305
  privateKey, msg = decode_private_key(enc.to_s, options)
@@ -971,6 +986,94 @@ class Oydid
971
986
  Base64.urlsafe_decode64(str + '=' * (4 - str.length % 4))
972
987
  end
973
988
 
989
+ def self.private_key_from_jwk(jwk, options = {})
990
+ begin
991
+ if jwk.is_a?(String)
992
+ jwk = JSON.parse(jwk)
993
+ end
994
+ rescue
995
+ return [nil, "invalid input"]
996
+ end
997
+ jwk = jwk.transform_keys(&:to_s)
998
+ if jwk["kty"] == "EC" && jwk["crv"] == "P-256"
999
+ digest = base64_url_decode(jwk["d"])
1000
+ group = OpenSSL::PKey::EC::Group.new("prime256v1")
1001
+ public_key = group.generator.mul(OpenSSL::BN.new(digest, 2))
1002
+ point = public_key.to_bn.to_s(2)
1003
+ x_calc = Base64.urlsafe_encode64(point[1, 32], padding: false)
1004
+ y_calc = Base64.urlsafe_encode64(point[33, 32], padding: false)
1005
+ return [nil, "x/y do not match d"] unless x_calc == jwk["x"] && y_calc == jwk["y"]
1006
+
1007
+ code = Multicodecs["p256-priv"].code
1008
+ length = digest.bytesize
1009
+ return multi_encode([code, length, digest].pack("SCa#{length}"), options)
1010
+ else
1011
+ return [nil, "unsupported key codec"]
1012
+ end
1013
+ end
1014
+
1015
+ # build an OYDID multibase-encoded private key from a raw hex-encoded key
1016
+ # (e.g. an externally generated P-256 or Ed25519 key). The key type is taken
1017
+ # from options[:key_type] (default 'ed25519').
1018
+ def self.private_key_from_hex(hex, options = {})
1019
+ hex = hex.to_s.strip.delete_prefix("0x").delete_prefix("0X")
1020
+ unless hex =~ /\A[0-9a-fA-F]+\z/ && hex.length.even?
1021
+ return [nil, "invalid hex input"]
1022
+ end
1023
+ raw = [hex].pack("H*")
1024
+ key_type = options[:key_type].to_s
1025
+ key_type = "ed25519" if key_type == ""
1026
+ case key_type
1027
+ when "p256"
1028
+ unless raw.bytesize == 32
1029
+ return [nil, "p256 private key must be 32 bytes (64 hex characters)"]
1030
+ end
1031
+ # scalar must be a valid private key in [1, n-1]
1032
+ order = OpenSSL::PKey::EC::Group.new("prime256v1").order
1033
+ scalar = OpenSSL::BN.new(raw, 2)
1034
+ if scalar < OpenSSL::BN.new(1) || scalar >= order
1035
+ return [nil, "p256 private key out of range"]
1036
+ end
1037
+ code = Multicodecs["p256-priv"].code
1038
+ when "ed25519"
1039
+ unless raw.bytesize == 32
1040
+ return [nil, "ed25519 private key must be 32 bytes (64 hex characters)"]
1041
+ end
1042
+ code = Multicodecs["ed25519-priv"].code
1043
+ else
1044
+ return [nil, "unsupported key type"]
1045
+ end
1046
+ length = raw.bytesize
1047
+ return multi_encode([code, length, raw].pack("SCa#{length}"), options)
1048
+ end
1049
+
1050
+ # reverse of private_key_from_hex / public_key encoding:
1051
+ # decode an OYDID Multibase-encoded key (private or public) back to raw hex.
1052
+ def self.key_to_hex(key_encoded, options = {})
1053
+ key_encoded = key_encoded.to_s.strip
1054
+ key_type = get_keytype(key_encoded) rescue nil
1055
+ if key_type.nil?
1056
+ return [nil, "unsupported or invalid key"]
1057
+ end
1058
+ begin
1059
+ raw = multi_decode(key_encoded).first
1060
+ case key_type
1061
+ when 'ed25519-priv', 'p256-priv'
1062
+ # custom layout: [uint16 codec][uint8 length][raw key bytes]
1063
+ _code, _length, digest = raw.unpack('SCa*')
1064
+ hex = digest.unpack1('H*')
1065
+ when 'ed25519-pub', 'p256-pub'
1066
+ # multicodec varint prefix (2 bytes) followed by raw key material
1067
+ hex = raw[2..].unpack1('H*')
1068
+ else
1069
+ return [nil, "unsupported key codec"]
1070
+ end
1071
+ rescue
1072
+ return [nil, "invalid key"]
1073
+ end
1074
+ return [hex, ""]
1075
+ end
1076
+
974
1077
  def self.public_key_from_jwk(jwk, options = {})
975
1078
  begin
976
1079
  if jwk.is_a?(String)
@@ -1043,6 +1146,11 @@ class Oydid
1043
1146
  end
1044
1147
 
1045
1148
  def self.retrieve_document(doc_identifier, doc_file, doc_location, options)
1149
+ # in-process callers can supply the DID document directly (e.g. read from
1150
+ # a local database) to avoid any HTTP/file lookup
1151
+ if options[:local_doc].is_a?(Hash) && !options[:local_doc].empty?
1152
+ return [options[:local_doc], ""]
1153
+ end
1046
1154
  if doc_location == ""
1047
1155
  doc_location = DEFAULT_LOCATION
1048
1156
  end
data/lib/oydid/log.rb CHANGED
@@ -24,6 +24,11 @@ class Oydid
24
24
  end
25
25
 
26
26
  def self.retrieve_log(did_hash, log_file, log_location, options)
27
+ # in-process callers can supply the raw log array directly (e.g. read from
28
+ # a local database) to avoid any HTTP/file lookup
29
+ if options[:local_log].is_a?(Array)
30
+ return [options[:local_log], ""]
31
+ end
27
32
  if log_location == ""
28
33
  log_location = DEFAULT_LOCATION
29
34
  end
@@ -136,6 +141,7 @@ class Oydid
136
141
  terminate_entries = 0
137
142
  terminate_overall = 0
138
143
  terminate_index = nil
144
+ revoked_terminate_found = false
139
145
  logs.each do |el|
140
146
  if el["op"].to_i == 0 # TERMINATE
141
147
  if dag.vertices[i].successors.length == 0
@@ -149,6 +155,7 @@ class Oydid
149
155
  dag.vertices[i].predecessors.each do |l|
150
156
  if logs[l[:id]]["op"].to_i == 0 # TERMINATE
151
157
  terminate_index = l[:id]
158
+ revoked_terminate_found = true
152
159
  end
153
160
  end
154
161
  end
@@ -156,11 +163,18 @@ class Oydid
156
163
  i += 1
157
164
  end unless logs.nil?
158
165
 
166
+ # NOTE: this is a structural check on the log and must not depend on how
167
+ # verbose the caller wants to be. Suppressing it under :silent returned a
168
+ # DID document for logs that cannot be resolved.
159
169
  if terminate_entries != 1 && !options[:log_complete] && !options[:followAlsoKnownAs]
160
- if options[:silent].nil? || !options[:silent]
170
+ # a revoked DID has no tangling TERMINATE entry (its TERMINATE is
171
+ # followed by the REVOKE record). That is not a broken log: continue
172
+ # so that dag_update can report the revocation - or apply DID
173
+ # Rotation - instead of the unspecific "cannot resolve DID".
174
+ unless terminate_entries == 0 && revoked_terminate_found
161
175
  return [nil, nil, nil, "cannot resolve DID" ]
162
176
  end
163
- end
177
+ end
164
178
 
165
179
  # create actual edges between vertices (but only use last terminate index for delegates)
166
180
  dag = DAG.new
@@ -249,8 +263,14 @@ class Oydid
249
263
  result.uniq
250
264
  end
251
265
 
266
+ # a DID is revoked when the REVOCATION record referenced by the current
267
+ # TERMINATE record has been published and no UPDATE record follows it
268
+ REVOKED_ERROR_CODE = 410
269
+
252
270
  def self.dag_update(currentDID, options)
253
271
  i = 0
272
+ revoked = false
273
+ rotation_performed = false
254
274
  doc_location = options[:doc_location].to_s
255
275
  initial_did = currentDID["did"].to_s.dup
256
276
  initial_did = initial_did.delete_prefix("did:oyd:")
@@ -496,6 +516,12 @@ class Oydid
496
516
  end
497
517
  end
498
518
 
519
+ # the revocation record is published and no UPDATE record
520
+ # builds on it: this DID is revoked. The verdict is only
521
+ # acted upon after the log has been walked completely, so
522
+ # that DID Rotation (handled in the REVOKE entry below) can
523
+ # still take precedence.
524
+ revoked = !update_term_found
499
525
  else
500
526
  if verification_output
501
527
  currentDID["verification"] += "Revocation reference in log record: " + revoc_term.to_s + "\n"
@@ -533,6 +559,7 @@ class Oydid
533
559
  # 2) das new DID reference back original DID
534
560
  currentDID["did"] = rotate_DID
535
561
  currentDID["doc"]["doc"] = rotate_ddoc
562
+ rotation_performed = true
536
563
  if verification_output
537
564
  currentDID["verification"] += "DID rotation to: " + rotate_DID.to_s + "\n"
538
565
  currentDID["verification"] += "✅ original DID (" + did_orig + ") revoked and referenced in alsoKnownAs\n"
@@ -558,6 +585,13 @@ class Oydid
558
585
  end
559
586
  i += 1
560
587
  end unless currentDID["log"].nil?
588
+
589
+ # fail closed: a revoked DID has no resolvable DID Document unless the
590
+ # controller rotated it to another DID via alsoKnownAs
591
+ if revoked && !rotation_performed
592
+ currentDID["error"] = REVOKED_ERROR_CODE
593
+ currentDID["message"] = "revoked"
594
+ end
561
595
  return currentDID
562
596
  end
563
597
  end
data/lib/oydid.rb CHANGED
@@ -541,6 +541,12 @@ class Oydid
541
541
  end
542
542
 
543
543
  def self.publish(did, didDocument, logs, options)
544
+ # in-process callers (e.g. a repository storing DIDs in its own database)
545
+ # can skip remote/file publishing and persist the returned logs themselves
546
+ if options[:skip_publish]
547
+ return [true, ""]
548
+ end
549
+
544
550
  did_hash = did.delete_prefix("did:oyd:")
545
551
  did10 = did_hash[0,10]
546
552
 
@@ -584,6 +590,13 @@ class Oydid
584
590
  end
585
591
 
586
592
  def self.persist_cmsm(pubkey, payload, options)
593
+ # in-process callers can supply a store object (responding to
594
+ # #set(pubkey, payload)) to persist CMSM data in a local database
595
+ if options[:cmsm_store]
596
+ options[:cmsm_store].set(pubkey, payload)
597
+ return [true, ""]
598
+ end
599
+
587
600
  doc_location = options[:doc_location]
588
601
  if doc_location.to_s == ""
589
602
  doc_location = DEFAULT_LOCATION
@@ -612,6 +625,17 @@ class Oydid
612
625
  end
613
626
 
614
627
  def self.check_cmsm(pubkey, options)
628
+ # in-process callers can supply a store object (responding to
629
+ # #get(pubkey)) to read CMSM data from a local database
630
+ if options[:cmsm_store]
631
+ payload = options[:cmsm_store].get(pubkey)
632
+ if payload.nil?
633
+ return [nil, "no persisted CMSM data for key"]
634
+ end
635
+ payload = payload.transform_keys(&:to_s) if payload.is_a?(Hash)
636
+ return [payload, ""]
637
+ end
638
+
615
639
  doc_location = options[:doc_location]
616
640
  if doc_location.to_s == ""
617
641
  doc_location = DEFAULT_LOCATION
@@ -937,7 +961,12 @@ class Oydid
937
961
  end
938
962
 
939
963
  # check if REVOCATION hash matches hash in TERMINATION
940
- if did_info["log"][did_info["termination_log_id"]]["doc"] != multi_hash(canonical(revocationLog), LOG_HASH_OPTIONS).first
964
+ # the "doc" field of the TERMINATE log entry may carry a location suffix
965
+ # (e.g. "<hash>@http://localhost:3000") when the DID was created with -l,
966
+ # so strip the location prefix before comparing with the bare revocation hash
967
+ termination_doc = did_info["log"][did_info["termination_log_id"]]["doc"]
968
+ termination_doc = termination_doc.split(LOCATION_PREFIX).first.split(CGI.escape LOCATION_PREFIX).first
969
+ if termination_doc != multi_hash(canonical(revocationLog), LOG_HASH_OPTIONS).first
941
970
  return [nil, "invalid revocation information"]
942
971
  end
943
972
  revoc_log = JSON.parse(revocationLog)
@@ -957,9 +986,17 @@ class Oydid
957
986
  did_hash = hash_split[0]
958
987
  doc_location = hash_split[1]
959
988
  end
989
+ if did_hash.include?(CGI.escape LOCATION_PREFIX)
990
+ hash_split = did_hash.split(CGI.escape LOCATION_PREFIX)
991
+ did_hash = hash_split[0]
992
+ doc_location = hash_split[1]
993
+ end
960
994
  if doc_location.to_s == ""
961
995
  doc_location = DEFAULT_LOCATION
962
996
  end
997
+ # the location extracted from the DID may be percent-encoded
998
+ # (e.g. "http%3A%2F%2Flocalhost%3A3000"); decode before using as URL
999
+ doc_location = doc_location.to_s.gsub("%3A", ":").gsub("%2F", "/")
963
1000
 
964
1001
  # publish revocation log based on location
965
1002
  case doc_location.to_s
@@ -986,6 +1023,11 @@ class Oydid
986
1023
  if revoc_log.nil?
987
1024
  return [nil, msg]
988
1025
  end
1026
+ # in-process callers persist the revocation log themselves; return it
1027
+ # instead of publishing to a remote location / file
1028
+ if options[:skip_publish]
1029
+ return [revoc_log, ""]
1030
+ end
989
1031
  success, msg = revoke_publish(did, revoc_log, options)
990
1032
  end
991
1033
 
data/spec/oydid_spec.rb CHANGED
@@ -167,6 +167,77 @@ describe "OYDID handling" do
167
167
  end
168
168
  end
169
169
 
170
+ # P-256 predefined / deterministic key handling
171
+ it "derives a deterministic p256 private key from a seed/password" do
172
+ key1, _ = Oydid.generate_private_key("my-fixed-seed", "p256-priv", {key_type: "p256"})
173
+ key2, _ = Oydid.generate_private_key("my-fixed-seed", "p256-priv", {key_type: "p256"})
174
+ expect(key1).not_to be_nil
175
+ expect(key1).to eq key2
176
+ expect(Oydid.get_keytype(key1)).to eq "p256-priv"
177
+ end
178
+ it "derives different p256 keys for different seeds" do
179
+ key1, _ = Oydid.generate_private_key("seed-a", "p256-priv", {})
180
+ key2, _ = Oydid.generate_private_key("seed-b", "p256-priv", {})
181
+ expect(key1).not_to eq key2
182
+ end
183
+ it "produces a usable p256 keypair from a derived key (sign/verify roundtrip)" do
184
+ privkey, _ = Oydid.generate_private_key("roundtrip-seed", "p256-priv", {})
185
+ pubkey, _ = Oydid.public_key(privkey, {})
186
+ expect(pubkey).not_to be_nil
187
+ message = "hello oydid"
188
+ signature, _ = Oydid.sign(message, privkey, {})
189
+ expect(signature).not_to be_nil
190
+ expect(Oydid.verify(message, signature, pubkey).first).to eq true
191
+ end
192
+ it "preserves a predefined base64-encoded p256 private key" do
193
+ ec = OpenSSL::PKey::EC.generate('prime256v1')
194
+ b64 = Base64.encode64(ec.to_pem)
195
+ encoded, _ = Oydid.generate_private_key(b64, "p256-priv", {})
196
+ expect(encoded).not_to be_nil
197
+ decoded = Oydid.decode_private_key(encoded).first
198
+ expect(decoded.private_key.to_s(2)).to eq ec.private_key.to_s(2)
199
+ end
200
+ it "getPrivateKey honors key_type for password-derived keys" do
201
+ p256_key, _ = Oydid.getPrivateKey(nil, "shared-pwd", nil, nil, {key_type: "p256"})
202
+ ed_key, _ = Oydid.getPrivateKey(nil, "shared-pwd", nil, nil, {key_type: "ed25519"})
203
+ expect(Oydid.get_keytype(p256_key)).to eq "p256-priv"
204
+ expect(Oydid.get_keytype(ed_key)).to eq "ed25519-priv"
205
+ end
206
+ it "imports a hex-encoded p256 private key (matching the JWK conversion)" do
207
+ hex = "96fe0f41947d645c7a1858c48c7a0560e7e5bd3d45125b57a611a3a9a103626b"
208
+ jwk = {kty: "EC", crv: "P-256",
209
+ d: "lv4PQZR9ZFx6GFjEjHoFYOflvT1FEltXphGjqaEDYms",
210
+ x: "vK0MQ6yFnQVS2VtjkVYHP5wcT7GqlJDzY5qM8KKqrao",
211
+ y: "R3AQWDZ-AAdwQ3sys1UwhIA5MX2WNnmSerQRKDKxg48"}
212
+ from_hex, _ = Oydid.private_key_from_hex(hex, {key_type: "p256"})
213
+ from_jwk, _ = Oydid.private_key_from_jwk(jwk.to_json, {})
214
+ expect(from_hex).not_to be_nil
215
+ expect(Oydid.get_keytype(from_hex)).to eq "p256-priv"
216
+ expect(from_hex).to eq from_jwk
217
+ end
218
+ it "rejects malformed hex private keys" do
219
+ expect(Oydid.private_key_from_hex("xyz", {key_type: "p256"}).first).to be_nil
220
+ expect(Oydid.private_key_from_hex("96fe", {key_type: "p256"}).first).to be_nil
221
+ expect(Oydid.private_key_from_hex("00" * 32, {key_type: "p256"}).first).to be_nil
222
+ end
223
+ it "round-trips a p256 private key hex -> mb -> hex" do
224
+ hex = "96fe0f41947d645c7a1858c48c7a0560e7e5bd3d45125b57a611a3a9a103626b"
225
+ mb, _ = Oydid.private_key_from_hex(hex, {key_type: "p256"})
226
+ back, _ = Oydid.key_to_hex(mb, {})
227
+ expect(back).to eq hex
228
+ end
229
+ it "decodes a p256 public key from mb to uncompressed hex" do
230
+ privkey, _ = Oydid.private_key_from_hex(
231
+ "96fe0f41947d645c7a1858c48c7a0560e7e5bd3d45125b57a611a3a9a103626b",
232
+ {key_type: "p256"})
233
+ pub_mb, _ = Oydid.public_key(privkey, {})
234
+ pub_hex, _ = Oydid.key_to_hex(pub_mb, {})
235
+ expect(pub_hex).to eq "04bcad0c43ac859d0552d95b639156073f9c1c4fb1aa9490f3639a8cf0a2aaadaa47701058367e000770437b32b35530848039317d963679927ab4112832b1838f"
236
+ end
237
+ it "rejects an invalid multibase key in key_to_hex" do
238
+ expect(Oydid.key_to_hex("not-a-key", {}).first).to be_nil
239
+ end
240
+
170
241
  # storage functions
171
242
  it "should create 'filename' and put/read 'text'" do
172
243
  @buffer = StringIO.new()
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: oydid
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.6.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christoph Fabianek
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-03-23 00:00:00.000000000 Z
10
+ date: 2026-08-18 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: simple_dag
@@ -415,14 +415,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
415
415
  requirements:
416
416
  - - ">="
417
417
  - !ruby/object:Gem::Version
418
- version: 3.2.8
418
+ version: 3.0.0
419
419
  required_rubygems_version: !ruby/object:Gem::Requirement
420
420
  requirements:
421
421
  - - ">="
422
422
  - !ruby/object:Gem::Version
423
423
  version: '0'
424
424
  requirements: []
425
- rubygems_version: 4.0.6
425
+ rubygems_version: 4.0.15
426
426
  specification_version: 4
427
427
  summary: Own Your Decentralized Identifier for Ruby.
428
428
  test_files: