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 +4 -4
- data/VERSION +1 -1
- data/lib/oydid/log.rb +32 -7
- data/lib/oydid.rb +9 -0
- data/spec/oydid_spec.rb +125 -0
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3aa2cc9a77716310c41f0bda9dae80ca4939602b778384c79d874e63e8a27000
|
|
4
|
+
data.tar.gz: cef9d037771c87c0bb4fb3614696c53c6a4b9d358f016f074defc37d88c810bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 43b1ef66a0a911c316a9c0a20d5018595ece344545faf4b986040210b10245f3557da15c5ade1681402ad816d918c75fd788fae5d788a5ba8a2469a39e5b7ee1
|
|
7
|
+
data.tar.gz: c08146faf1769b6f782e50bbd23468629e7a9069c9e20e9c6bee90cf0dce9a68f494aeceeb57b23d972bd331a73c1643a58bcac91cb73df2238eff33df0061e5
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.9.
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
#
|
|
473
|
-
|
|
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,
|
|
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.
|
|
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-
|
|
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.
|
|
436
|
+
rubygems_version: 4.0.15
|
|
437
437
|
specification_version: 4
|
|
438
438
|
summary: Own Your Decentralized Identifier for Ruby.
|
|
439
439
|
test_files:
|