oydid 0.9.2 → 0.9.4

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: 072b6ccce2a1b7bf3c607aa01d3a3c97cfd37f8e41d52bb9db529d539ccb6eda
4
- data.tar.gz: 75ccf1a0357d60e629dac7ea3a046cfe0933693a04b5179bf0db7b54929abc6b
3
+ metadata.gz: 0f96270f4b2465c3dadb499841094e20c27c1e6512e18f69c2e2f7dbb2c6559f
4
+ data.tar.gz: 0aff913041f3105b4569c41c88918439b50754431e24c85e51197f851fd436d9
5
5
  SHA512:
6
- metadata.gz: c936a18cf7106ae73e76fb30fc5ee789a50330a342dfa9624f29bd03574cde42b9c8a4b6a710e0f7e3ed9c6865fb8ec49aaac2cf7cd24c5d5330405e4f20cd81
7
- data.tar.gz: 3d6c15c81d795cd470f75031722f211aac478dc239a1ece245a64e1b00144d7d37eadfe3c5db8dab4ebfd487e685011f50615f5c40d05ea502d59762cbb51a1f
6
+ metadata.gz: 2505aeb0d16fc4a7786b24bbd1dcd607e7ac71122738ddc0bff9c6880d813813c5e6f2c134977a4bbf92b765565b8899822ae5d66dfe1977f214b89960ed6733
7
+ data.tar.gz: 49b7a640acf6793d33a504b45c4f69b30767a86d95fe4f7fd3236c26e4494456ccf4e799effe12c856b85a2ab675fa9887664c934bdcdd93710381466af27145
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.2
1
+ 0.9.4
data/lib/oydid/basic.rb CHANGED
@@ -1248,6 +1248,30 @@ class Oydid
1248
1248
  end
1249
1249
  end
1250
1250
 
1251
+ # Marks a failure of the queried repository itself (HTTP 5xx) as opposed to
1252
+ # a DID that is simply not stored there. Without the distinction a caller
1253
+ # turns a broken upstream into "not found", which claims the identifier
1254
+ # never existed - see upstream_error?.
1255
+ UPSTREAM_ERROR_PREFIX = "upstream error"
1256
+
1257
+ def self.upstream_error?(message)
1258
+ message.to_s.start_with?(UPSTREAM_ERROR_PREFIX)
1259
+ end
1260
+
1261
+ # Failure message for a non-200 response: the repository's own error field
1262
+ # when it sent one, otherwise a message naming status and URL. A 5xx gets
1263
+ # the prefix above so callers can tell it apart from a missing DID.
1264
+ def self.http_error_message(response, url)
1265
+ msg = (response.parsed_response["error"].to_s rescue "")
1266
+ return msg unless msg.to_s == ""
1267
+ code = response.code.to_i rescue 0
1268
+ if code >= 500
1269
+ UPSTREAM_ERROR_PREFIX + " " + code.to_s + " from " + url.to_s
1270
+ else
1271
+ "invalid response from " + url.to_s
1272
+ end
1273
+ end
1274
+
1251
1275
  def self.retrieve_document(doc_identifier, doc_file, doc_location, options)
1252
1276
  # in-process callers can supply the DID document directly (e.g. read from
1253
1277
  # a local database) to avoid any HTTP/file lookup
@@ -1271,11 +1295,7 @@ class Oydid
1271
1295
  end
1272
1296
  retVal = HTTParty.get(doc_location + "/doc/" + doc_identifier + option_str)
1273
1297
  if retVal.code != 200
1274
- msg = retVal.parsed_response["error"].to_s rescue ""
1275
- if msg.to_s == ""
1276
- msg = "invalid response from " + doc_location.to_s + "/doc/" + doc_identifier.to_s
1277
- end
1278
- return [nil, msg]
1298
+ return [nil, http_error_message(retVal, doc_location.to_s + "/doc/" + doc_identifier.to_s)]
1279
1299
  end
1280
1300
  if options.transform_keys(&:to_s)["trace"]
1281
1301
  if options[:silent].nil? || !options[:silent]
@@ -1324,8 +1344,7 @@ class Oydid
1324
1344
  doc_location = doc_location.sub("%3A%2F%2F","://").sub("%3A", ":")
1325
1345
  retVal = HTTParty.get(doc_location + "/doc_raw/" + doc_hash)
1326
1346
  if retVal.code != 200
1327
- msg = retVal.parsed_response("error").to_s rescue "invalid response from " + doc_location.to_s + "/doc/" + doc_hash.to_s
1328
- return [nil, msg]
1347
+ return [nil, http_error_message(retVal, doc_location.to_s + "/doc_raw/" + doc_hash.to_s)]
1329
1348
  end
1330
1349
  if options.transform_keys(&:to_s)["trace"]
1331
1350
  if options[:silent].nil? || !options[:silent]
data/lib/oydid/log.rb CHANGED
@@ -44,10 +44,7 @@ class Oydid
44
44
  log_location = log_location.gsub("%2F%2F","//")
45
45
  retVal = HTTParty.get(log_location + "/log/" + did_hash)
46
46
  if retVal.code != 200
47
- msg = retVal.parsed_response("error").to_s rescue
48
- "invalid response from " + log_location.to_s + "/log/" + did_hash.to_s
49
-
50
- return [nil, msg]
47
+ return [nil, http_error_message(retVal, log_location.to_s + "/log/" + did_hash.to_s)]
51
48
  end
52
49
  if options.transform_keys(&:to_s)["trace"]
53
50
  if options[:silent].nil? || !options[:silent]
@@ -79,9 +76,7 @@ class Oydid
79
76
  log_location = log_location.gsub("%2F%2F","//")
80
77
  retVal = HTTParty.get(log_location + "/log/" + log_hash + "/item")
81
78
  if retVal.code != 200
82
- msg = retVal.parsed_response("error").to_s rescue
83
- "invalid response from " + log_location.to_s + "/log/" + log_hash.to_s + "/item"
84
- return [nil, msg]
79
+ return [nil, http_error_message(retVal, log_location.to_s + "/log/" + log_hash.to_s + "/item")]
85
80
  end
86
81
  if options.transform_keys(&:to_s)["trace"]
87
82
  if options[:silent].nil? || !options[:silent]
@@ -132,7 +127,7 @@ class Oydid
132
127
  if !position.nil?
133
128
  dag.add_edge from: dag_log[position], to: dag_log[i]
134
129
  end
135
- end unless el["previous"] == []
130
+ end unless el["previous"].nil? || el["previous"] == []
136
131
  i += 1
137
132
  end unless logs.nil?
138
133
 
@@ -212,7 +207,7 @@ class Oydid
212
207
  dag.add_edge from: dag_log[position], to: dag_log[i]
213
208
  end
214
209
  end
215
- end unless el["previous"] == []
210
+ end unless el["previous"].nil? || el["previous"] == []
216
211
  i += 1
217
212
  end unless logs.nil?
218
213
 
@@ -472,10 +467,20 @@ class Oydid
472
467
  message = log_el["doc"].to_s
473
468
  signature = log_el["sig"]
474
469
  # public_key = current_public_doc_key.to_s
475
- extend_currentDID = currentDID.dup
476
- extend_currentDID["log"] = extend_currentDID["full_log"]
477
- # !!!TODO: check for delegates only at certain point in time
478
- pubKeys, msg = Oydid.getDelegatedPubKeysFromFullDidDocument(extend_currentDID, "doc")
470
+ # SECURITY (fail-closed on delegation): an UPDATE
471
+ # is authorised only by the document key of the
472
+ # version it supersedes - the key continuity that
473
+ # anchors the chain. Delegated keys (op=5) are NOT
474
+ # honoured at resolution: they were collected from
475
+ # the raw log with no authentication whatsoever, so
476
+ # honouring them let anyone able to write a log
477
+ # entry take over a revoked DID by injecting a
478
+ # DELEGATE plus a self-signed UPDATE. No production
479
+ # DID relies on a delegated key to resolve
480
+ # (audit 2026-09-01), so this closes the hole at
481
+ # zero cost.
482
+ pubKeys = [current_public_doc_key.to_s]
483
+ msg = ""
479
484
  signature_verification = false
480
485
  used_pubkey = ""
481
486
  pubKeys.each do |key|
@@ -520,7 +525,7 @@ class Oydid
520
525
  new_did_hash = new_doc_did.delete_prefix("did:oyd:")
521
526
  new_did_hash = new_did_hash.split(LOCATION_PREFIX).first.split(CGI.escape LOCATION_PREFIX).first
522
527
  new_did10 = new_did_hash[0,10]
523
- new_doc = retrieve_document(new_doc_did, new_did10 + ".doc", new_doc_location, {}).first
528
+ new_doc = retrieve_document(new_doc_did, new_did10 + ".doc", new_doc_location, options).first
524
529
  currentDID["verification"] += "found UPDATE log record:" + "\n"
525
530
  currentDID["verification"] += JSON.pretty_generate(log_el) + "\n"
526
531
  currentDID["verification"] += "⛔ none of available public keys (" + pubKeys.join(", ") + ")\n"
@@ -558,9 +563,22 @@ class Oydid
558
563
  # handle DID Rotation
559
564
  if (i == (currentDID["log"].length-1))
560
565
  if options[:followAlsoKnownAs]
566
+ # the payload of a DID Document is arbitrary JSON: an
567
+ # Array or a scalar is valid and simply carries no
568
+ # alsoKnownAs, so only a Hash can be inspected for it.
569
+ # Without this guard a revoked DID whose document holds
570
+ # e.g. [5] raised NoMethodError here, the repository
571
+ # answered 500 with an empty body, and the resolver
572
+ # degraded that to "not found" instead of "deactivated".
561
573
  current_doc = currentDID["doc"]
562
- if current_doc["doc"].transform_keys(&:to_s).has_key?("alsoKnownAs")
563
- rotate_DID = current_doc["doc"].transform_keys(&:to_s)["alsoKnownAs"]
574
+ doc_payload = current_doc.is_a?(Hash) ? current_doc["doc"] : nil
575
+ doc_payload = doc_payload.transform_keys(&:to_s) if doc_payload.is_a?(Hash)
576
+ if doc_payload.is_a?(Hash) && doc_payload.has_key?("alsoKnownAs")
577
+ # DID Core defines alsoKnownAs as a set; older
578
+ # documents wrote a bare String
579
+ rotate_DID = doc_payload["alsoKnownAs"]
580
+ rotate_DID = rotate_DID.first if rotate_DID.is_a?(Array)
581
+ rotate_DID = rotate_DID.to_s
564
582
  if rotate_DID.start_with?("did:")
565
583
  rotate_DID_method = rotate_DID.split(":").take(2).join(":")
566
584
  did_orig = currentDID["did"]
@@ -586,8 +604,14 @@ class Oydid
586
604
  currentDID["verification"] += "(Details: https://ownyourdata.github.io/oydid/#did_rotation)" + "\n\n"
587
605
  end
588
606
  when "did:oyd"
589
- puts "try to resolve did:oyd with our own resolver"
590
- puts "add verification text"
607
+ # DID Rotation to another did:oyd identifier
608
+ # is not implemented yet: resolving it needs
609
+ # a second pass through this resolver.
610
+ if verification_output
611
+ currentDID["verification"] += "DID rotation to: " + rotate_DID.to_s + "\n"
612
+ currentDID["verification"] += "⚠️ rotation to another did:oyd identifier is not supported yet\n"
613
+ currentDID["verification"] += "(Details: https://ownyourdata.github.io/oydid/#did_rotation)" + "\n\n"
614
+ end
591
615
  else
592
616
  # do nothing: DID Rotation is not supported for this DID method yet
593
617
  end
data/spec/oydid_spec.rb CHANGED
@@ -519,6 +519,159 @@ describe "OYDID handling" do
519
519
  # end
520
520
  # end
521
521
 
522
+ # regression: the payload of a DID Document is arbitrary JSON, not
523
+ # necessarily an object. A revoked DID whose payload is an Array used to
524
+ # raise NoMethodError in the alsoKnownAs lookup - the repository then
525
+ # answered 500 with an empty body and the resolver reported "not found"
526
+ # instead of "deactivated".
527
+ describe "dag_update with a non-object DID Document payload" do
528
+ def revoked_state(payload)
529
+ { "did" => "did:oyd:zQmPyjnkL52gQxBBhPuCFkf167MzpCKquqbu5Qt5ia2JGTr",
530
+ "doc" => { "doc" => payload, "key" => "", "log" => "" },
531
+ "log" => [{ "ts" => 1647732475, "op" => 1, "doc" => "zQmcVLwEtzU8By7TTeRFGvVUKm7HhZS6GgRTDFt51QNnans",
532
+ "sig" => "", "previous" => [] }] }
533
+ end
534
+
535
+ it "does not raise for an Array payload" do
536
+ expect {
537
+ Oydid.dag_update(revoked_state([5]), { followAlsoKnownAs: true })
538
+ }.not_to raise_error
539
+ end
540
+
541
+ it "does not raise for a scalar payload" do
542
+ expect {
543
+ Oydid.dag_update(revoked_state("plain"), { followAlsoKnownAs: true })
544
+ }.not_to raise_error
545
+ end
546
+
547
+ it "accepts alsoKnownAs written as a set" do
548
+ state = revoked_state({ "alsoKnownAs" => ["did:oyd:zQmaBZTghndXTgxNwfbdpVLWdFf6faYE4oeuN2zzXdQt1kh"] })
549
+ expect {
550
+ Oydid.dag_update(state, { followAlsoKnownAs: true })
551
+ }.not_to raise_error
552
+ end
553
+ end
554
+
555
+ # regression: a log entry with a missing "previous" field (e.g. junk op=5
556
+ # entries POSTed to the unauthenticated /log endpoint) made dag_did call
557
+ # nil.each - the repository then answered 500 with an empty body for every
558
+ # request to that DID. previous is optional; a missing one means no
559
+ # back-reference, exactly like an empty array.
560
+ describe "dag_did with a log entry missing 'previous'" do
561
+ let(:log) do
562
+ [ { "ts" => 0, "op" => 5, "doc" => "doc:123", "sig" => "z6M234" }, # no "previous"
563
+ { "ts" => 1, "op" => 2, "doc" => "zQmSVzALxVDs2Tqf5gh9XDWSL4L57YLi8H1XpvpizQPd79c", "sig" => "z", "previous" => [] },
564
+ { "ts" => 1, "op" => 0, "doc" => "zQmXRVzdjMnTz1WQVkVdJU1ZF9DPjcK5LM2GynpGzxbNAz6", "sig" => "z", "previous" => [] } ]
565
+ end
566
+
567
+ it "does not raise on a missing previous field" do
568
+ expect { Oydid.dag_did(log, { silent: true }) }.not_to raise_error
569
+ end
570
+
571
+ it "still resolves the DAG and finds the single CREATE entry" do
572
+ dag, create_index, _terminate_index, msg = Oydid.dag_did(log, { silent: true })
573
+ expect(msg).to eq ""
574
+ expect(dag).not_to be_nil
575
+ expect(create_index).to eq 1
576
+ end
577
+ end
578
+
579
+ # SECURITY regression (fail-closed on delegation): a DELEGATE entry (op=5) is
580
+ # collected from the raw log with no authentication. Honouring it at resolution
581
+ # let anyone able to write a log entry take over a revoked DID by injecting a
582
+ # DELEGATE plus a self-signed UPDATE. Resolution must authorise an UPDATE only
583
+ # by the superseded version's own document key - and must keep resolving a
584
+ # legitimate owner-signed update.
585
+ describe "DELEGATE keys are not honoured at resolution" do
586
+ HOPTS = { digest: "sha2-256", encode: "base58btc" }
587
+ class MemStore
588
+ def initialize(h); @h = h; end
589
+ def get(k); @h[k]; end
590
+ end
591
+ def h(e); Oydid.multi_hash(Oydid.canonical(e.slice("ts","op","doc","sig","previous")), HOPTS).first; end
592
+ def sub(e); Oydid.multi_hash(Oydid.canonical(e.slice("ts","op","doc","sig")), HOPTS).first; end
593
+
594
+ # generate_base builds the CREATE/TERMINATE/REVOKE records and the keys
595
+ # without the network round-trip that Oydid.create makes via w3c (which
596
+ # WebMock blocks in the test env). doc_location "local" keeps the log
597
+ # reference free of an @location suffix.
598
+ def build_revoked_victim
599
+ dd, dk, dl, = Oydid.generate_base({ "purpose" => "victim" }, nil, "create",
600
+ { key_type: "ed25519", doc_location: "local" })
601
+ create = dl[:l1]
602
+ term1 = dl[:l2]
603
+ revoke = dl[:r1].merge("previous" => [h(create), h(term1)])
604
+ { vdid: dd[:did].delete_prefix("did:oyd:"), vdoc: dd[:didDocument], vpriv: dk[:privateKey],
605
+ create: create, term1: term1, revoke: revoke }
606
+ end
607
+
608
+ def build_v2
609
+ np = { "service" => [{ "id" => "#x", "serviceEndpoint" => "https://new.example" }] }
610
+ apriv = Oydid.generate_private_key("", "ed25519-priv", {}).first
611
+ apub = Oydid.public_key(apriv, {}).first
612
+ arpub = Oydid.public_key(Oydid.generate_private_key("", "ed25519-priv", {}).first, {}).first
613
+ nkey = "#{apub}:#{arpub}"
614
+ r2 = { "ts" => 3, "op" => 1, "sig" => "z",
615
+ "doc" => Oydid.multi_hash(Oydid.canonical({ "doc" => np, "key" => nkey }.to_json), HOPTS).first }
616
+ term2 = { "ts" => 3, "op" => 0, "doc" => sub(r2), "sig" => Oydid.sign(sub(r2), apriv, HOPTS).first, "previous" => [] }
617
+ ndoc = { "doc" => np, "key" => nkey, "log" => h(term2) }
618
+ { np: np, apriv: apriv, apub: apub, term2: term2, ndoc: ndoc,
619
+ ndid: Oydid.multi_hash(Oydid.canonical(ndoc.to_json), HOPTS).first }
620
+ end
621
+
622
+ def resolve(vic, v2, log)
623
+ store = MemStore.new(vic[:vdid] => { "doc" => vic[:vdoc], "log" => log },
624
+ v2[:ndid] => { "doc" => v2[:ndoc], "log" => log })
625
+ r, = Oydid.read("did:oyd:#{vic[:vdid]}",
626
+ { local_doc: vic[:vdoc], local_log: log, local_store: store, silent: true })
627
+ [r["error"].to_i, r.dig("doc", "doc")]
628
+ end
629
+
630
+ it "rejects a takeover via an injected DELEGATE + self-signed UPDATE" do
631
+ vic = build_revoked_victim; v2 = build_v2
632
+ deleg = { "ts" => 2, "op" => 5, "doc" => "doc:#{v2[:apub]}", "sig" => "", "previous" => [h(vic[:term1])] }
633
+ upd = { "ts" => 3, "op" => 3, "doc" => v2[:ndid],
634
+ "sig" => Oydid.sign(v2[:ndid], v2[:apriv], HOPTS).first, "previous" => [h(vic[:revoke])] }
635
+ _err, doc = resolve(vic, v2, [vic[:create], vic[:term1], vic[:revoke], deleg, upd, v2[:term2]])
636
+ expect(doc).not_to eq v2[:np]
637
+ end
638
+
639
+ it "still resolves a legitimate owner-signed UPDATE" do
640
+ vic = build_revoked_victim; v2 = build_v2
641
+ upd = { "ts" => 3, "op" => 3, "doc" => v2[:ndid],
642
+ "sig" => Oydid.sign(v2[:ndid], vic[:vpriv], HOPTS).first, "previous" => [h(vic[:revoke])] }
643
+ err, doc = resolve(vic, v2, [vic[:create], vic[:term1], vic[:revoke], upd, v2[:term2]])
644
+ expect(err).to eq 0
645
+ expect(doc).to eq v2[:np]
646
+ end
647
+ end
648
+
649
+ # a broken repository (5xx) has to stay distinguishable from a DID that is
650
+ # not stored there - otherwise the caller reports "not found" and thereby
651
+ # claims the identifier never existed
652
+ describe "failure messages for non-200 responses" do
653
+ it "marks a 5xx as an upstream error" do
654
+ reply = double(code: 500, parsed_response: "")
655
+ msg = Oydid.http_error_message(reply, "https://oydid.ownyourdata.eu/doc/zQmXY")
656
+ expect(Oydid.upstream_error?(msg)).to be true
657
+ expect(msg).to include("500")
658
+ end
659
+
660
+ it "passes the repository's own error through" do
661
+ reply = double(code: 410, parsed_response: { "error" => "revoked" })
662
+ msg = Oydid.http_error_message(reply, "https://oydid.ownyourdata.eu/doc/zQmXY")
663
+ expect(msg).to eq "revoked"
664
+ expect(Oydid.upstream_error?(msg)).to be false
665
+ end
666
+
667
+ it "does not mark a 4xx without an error field as upstream error" do
668
+ reply = double(code: 404, parsed_response: nil)
669
+ msg = Oydid.http_error_message(reply, "https://oydid.ownyourdata.eu/doc/zQmXY")
670
+ expect(msg).to start_with("invalid response from")
671
+ expect(Oydid.upstream_error?(msg)).to be false
672
+ end
673
+ end
674
+
522
675
  # main functionds
523
676
  Dir.glob(File.expand_path("../input/main/*_read.doc", __FILE__)).each do |input|
524
677
  it "execute read for #{input.split('/').last}" do
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.9.2
4
+ version: 0.9.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christoph Fabianek
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-08-28 00:00:00.000000000 Z
10
+ date: 2026-09-01 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: simple_dag
@@ -433,7 +433,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
433
433
  - !ruby/object:Gem::Version
434
434
  version: '0'
435
435
  requirements: []
436
- rubygems_version: 4.0.19
436
+ rubygems_version: 4.0.15
437
437
  specification_version: 4
438
438
  summary: Own Your Decentralized Identifier for Ruby.
439
439
  test_files: