oydid 0.9.3 → 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.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/VERSION +1 -1
  3. data/lib/oydid/log.rb +17 -7
  4. data/spec/oydid_spec.rb +94 -0
  5. metadata +3 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b941c83aec6492dfb37d272fd83a77f1b036902b70aab64634fed657d1ccaf6a
4
- data.tar.gz: 0ba0f288c5c30e2cd7d7d831bd072ef0c4924082d70fba44634e1412578a8dc7
3
+ metadata.gz: 0f96270f4b2465c3dadb499841094e20c27c1e6512e18f69c2e2f7dbb2c6559f
4
+ data.tar.gz: 0aff913041f3105b4569c41c88918439b50754431e24c85e51197f851fd436d9
5
5
  SHA512:
6
- metadata.gz: ddc6020c44371fee58f2e527513e4d94974ee5d4a3ebabd22a01b5cc039f5c91cfe1a0ab0f4046c50a356ab0784db6a626c971c5ee18e10b076e03625250605e
7
- data.tar.gz: eaba43282881c1239cde7af7733707d5beff7670b27388327926b4e7a5c739e9c221c1f9b70565a629adb3e7b4ee43dd3ebb3cb977bf6b2dd885ff6f55a34e93
6
+ metadata.gz: 2505aeb0d16fc4a7786b24bbd1dcd607e7ac71122738ddc0bff9c6880d813813c5e6f2c134977a4bbf92b765565b8899822ae5d66dfe1977f214b89960ed6733
7
+ data.tar.gz: 49b7a640acf6793d33a504b45c4f69b30767a86d95fe4f7fd3236c26e4494456ccf4e799effe12c856b85a2ab675fa9887664c934bdcdd93710381466af27145
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.3
1
+ 0.9.4
data/lib/oydid/log.rb CHANGED
@@ -127,7 +127,7 @@ class Oydid
127
127
  if !position.nil?
128
128
  dag.add_edge from: dag_log[position], to: dag_log[i]
129
129
  end
130
- end unless el["previous"] == []
130
+ end unless el["previous"].nil? || el["previous"] == []
131
131
  i += 1
132
132
  end unless logs.nil?
133
133
 
@@ -207,7 +207,7 @@ class Oydid
207
207
  dag.add_edge from: dag_log[position], to: dag_log[i]
208
208
  end
209
209
  end
210
- end unless el["previous"] == []
210
+ end unless el["previous"].nil? || el["previous"] == []
211
211
  i += 1
212
212
  end unless logs.nil?
213
213
 
@@ -467,10 +467,20 @@ class Oydid
467
467
  message = log_el["doc"].to_s
468
468
  signature = log_el["sig"]
469
469
  # public_key = current_public_doc_key.to_s
470
- extend_currentDID = currentDID.dup
471
- extend_currentDID["log"] = extend_currentDID["full_log"]
472
- # !!!TODO: check for delegates only at certain point in time
473
- 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 = ""
474
484
  signature_verification = false
475
485
  used_pubkey = ""
476
486
  pubKeys.each do |key|
@@ -515,7 +525,7 @@ class Oydid
515
525
  new_did_hash = new_doc_did.delete_prefix("did:oyd:")
516
526
  new_did_hash = new_did_hash.split(LOCATION_PREFIX).first.split(CGI.escape LOCATION_PREFIX).first
517
527
  new_did10 = new_did_hash[0,10]
518
- 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
519
529
  currentDID["verification"] += "found UPDATE log record:" + "\n"
520
530
  currentDID["verification"] += JSON.pretty_generate(log_el) + "\n"
521
531
  currentDID["verification"] += "⛔ none of available public keys (" + pubKeys.join(", ") + ")\n"
data/spec/oydid_spec.rb CHANGED
@@ -552,6 +552,100 @@ describe "OYDID handling" do
552
552
  end
553
553
  end
554
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
+
555
649
  # a broken repository (5xx) has to stay distinguishable from a DID that is
556
650
  # not stored there - otherwise the caller reports "not found" and thereby
557
651
  # claims the identifier never existed
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.3
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-29 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: