oydid 0.9.3 → 0.9.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: b941c83aec6492dfb37d272fd83a77f1b036902b70aab64634fed657d1ccaf6a
4
- data.tar.gz: 0ba0f288c5c30e2cd7d7d831bd072ef0c4924082d70fba44634e1412578a8dc7
3
+ metadata.gz: 3aa2cc9a77716310c41f0bda9dae80ca4939602b778384c79d874e63e8a27000
4
+ data.tar.gz: cef9d037771c87c0bb4fb3614696c53c6a4b9d358f016f074defc37d88c810bd
5
5
  SHA512:
6
- metadata.gz: ddc6020c44371fee58f2e527513e4d94974ee5d4a3ebabd22a01b5cc039f5c91cfe1a0ab0f4046c50a356ab0784db6a626c971c5ee18e10b076e03625250605e
7
- data.tar.gz: eaba43282881c1239cde7af7733707d5beff7670b27388327926b4e7a5c739e9c221c1f9b70565a629adb3e7b4ee43dd3ebb3cb977bf6b2dd885ff6f55a34e93
6
+ metadata.gz: 43b1ef66a0a911c316a9c0a20d5018595ece344545faf4b986040210b10245f3557da15c5ade1681402ad816d918c75fd788fae5d788a5ba8a2469a39e5b7ee1
7
+ data.tar.gz: c08146faf1769b6f782e50bbd23468629e7a9069c9e20e9c6bee90cf0dce9a68f494aeceeb57b23d972bd331a73c1643a58bcac91cb73df2238eff33df0061e5
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.3
1
+ 0.9.5
data/lib/oydid/log.rb CHANGED
@@ -90,6 +90,21 @@ class Oydid
90
90
  end
91
91
  end
92
92
 
93
+ # collapse byte-identical log entries (keep first), keyed on the entry hash
94
+ # that `previous` references resolve to. A replayed CREATE or tangling
95
+ # TERMINATE would otherwise trip dag_did's CREATE/terminate counts and make
96
+ # the DID unresolvable - reachable through the unauthenticated append path
97
+ # with no signing key. Genuinely distinct fork entries (different hash) are
98
+ # kept and still fail closed as ambiguous.
99
+ def self.dedup_log(logs)
100
+ return logs unless logs.is_a?(Array)
101
+ seen = {}
102
+ logs.select do |el|
103
+ key = (canonical(el.slice("ts","op","doc","sig","previous")) rescue el.inspect)
104
+ seen.key?(key) ? false : (seen[key] = true)
105
+ end
106
+ end
107
+
93
108
  def self.dag_did(logs, options)
94
109
  dag = DAG.new
95
110
  dag_log = []
@@ -127,7 +142,7 @@ class Oydid
127
142
  if !position.nil?
128
143
  dag.add_edge from: dag_log[position], to: dag_log[i]
129
144
  end
130
- end unless el["previous"] == []
145
+ end unless el["previous"].nil? || el["previous"] == []
131
146
  i += 1
132
147
  end unless logs.nil?
133
148
 
@@ -207,7 +222,7 @@ class Oydid
207
222
  dag.add_edge from: dag_log[position], to: dag_log[i]
208
223
  end
209
224
  end
210
- end unless el["previous"] == []
225
+ end unless el["previous"].nil? || el["previous"] == []
211
226
  i += 1
212
227
  end unless logs.nil?
213
228
 
@@ -467,10 +482,20 @@ class Oydid
467
482
  message = log_el["doc"].to_s
468
483
  signature = log_el["sig"]
469
484
  # 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")
485
+ # SECURITY (fail-closed on delegation): an UPDATE
486
+ # is authorised only by the document key of the
487
+ # version it supersedes - the key continuity that
488
+ # anchors the chain. Delegated keys (op=5) are NOT
489
+ # honoured at resolution: they were collected from
490
+ # the raw log with no authentication whatsoever, so
491
+ # honouring them let anyone able to write a log
492
+ # entry take over a revoked DID by injecting a
493
+ # DELEGATE plus a self-signed UPDATE. No production
494
+ # DID relies on a delegated key to resolve
495
+ # (audit 2026-09-01), so this closes the hole at
496
+ # zero cost.
497
+ pubKeys = [current_public_doc_key.to_s]
498
+ msg = ""
474
499
  signature_verification = false
475
500
  used_pubkey = ""
476
501
  pubKeys.each do |key|
@@ -515,7 +540,7 @@ class Oydid
515
540
  new_did_hash = new_doc_did.delete_prefix("did:oyd:")
516
541
  new_did_hash = new_did_hash.split(LOCATION_PREFIX).first.split(CGI.escape LOCATION_PREFIX).first
517
542
  new_did10 = new_did_hash[0,10]
518
- new_doc = retrieve_document(new_doc_did, new_did10 + ".doc", new_doc_location, {}).first
543
+ new_doc = retrieve_document(new_doc_did, new_did10 + ".doc", new_doc_location, options).first
519
544
  currentDID["verification"] += "found UPDATE log record:" + "\n"
520
545
  currentDID["verification"] += JSON.pretty_generate(log_el) + "\n"
521
546
  currentDID["verification"] += "⛔ none of available public keys (" + pubKeys.join(", ") + ")\n"
data/lib/oydid.rb CHANGED
@@ -142,6 +142,14 @@ class Oydid
142
142
  log_hash = hash_split[0]
143
143
  log_location = hash_split[1]
144
144
  end
145
+ # D11: the log reference may carry its location as %40 (the
146
+ # W3C-conform form of @); split on both, exactly as the DID split
147
+ # above does.
148
+ if log_hash.include?(CGI.escape LOCATION_PREFIX)
149
+ hash_split = log_hash.split(CGI.escape LOCATION_PREFIX)
150
+ log_hash = hash_split[0]
151
+ log_location = hash_split[1]
152
+ end
145
153
  end
146
154
  if log_location == ""
147
155
  log_location = DEFAULT_LOCATION
@@ -152,6 +160,7 @@ class Oydid
152
160
  if log_array.nil?
153
161
  return [nil, msg]
154
162
  else
163
+ log_array = dedup_log(log_array)
155
164
  if options[:trace]
156
165
  puts " .. Log retrieved"
157
166
  end
data/spec/oydid_spec.rb CHANGED
@@ -552,6 +552,131 @@ 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
+
649
+ # SECURITY regression: the append endpoint is unauthenticated, so a byte-identical
650
+ # log entry can be replayed with no signing key. A duplicated CREATE trips dag_did's
651
+ # "wrong number of CREATE entries" and a duplicated tangling TERMINATE its terminate
652
+ # count, making the DID unresolvable. dedup_log collapses byte-identical replays
653
+ # (keep-first) at ingestion; genuinely distinct fork entries survive and still fail
654
+ # closed as ambiguous.
655
+ describe "dedup_log collapses byte-identical replayed entries" do
656
+ let(:create_e) { { "ts" => 1, "op" => 2, "doc" => "zCreate", "sig" => "z", "previous" => [] } }
657
+ let(:term_e) { { "ts" => 1, "op" => 0, "doc" => "zTerm", "sig" => "z", "previous" => [] } }
658
+
659
+ it "removes a byte-identical duplicate, keeping the first" do
660
+ deduped = Oydid.dedup_log([create_e, term_e, create_e.dup])
661
+ expect(deduped.length).to eq 2
662
+ expect(deduped.count { |e| e["op"] == 2 }).to eq 1
663
+ end
664
+
665
+ it "lets a de-duplicated log resolve where the raw one fails" do
666
+ logs = [create_e, term_e, create_e.dup]
667
+ expect(Oydid.dag_did(logs, { silent: true }).last).to match(/wrong number of CREATE/)
668
+ dag, create_index, _t, msg = Oydid.dag_did(Oydid.dedup_log(logs), { silent: true })
669
+ expect(msg).to eq ""
670
+ expect(dag).not_to be_nil
671
+ expect(create_index).to eq 0
672
+ end
673
+
674
+ it "keeps genuinely distinct entries (a real fork is not collapsed)" do
675
+ fork = create_e.merge("doc" => "zCreate2")
676
+ expect(Oydid.dedup_log([create_e, fork, term_e]).length).to eq 3
677
+ end
678
+ end
679
+
555
680
  # a broken repository (5xx) has to stay distinguishable from a DID that is
556
681
  # not stored there - otherwise the caller reports "not found" and thereby
557
682
  # 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.5
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-02 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: