oydid 0.9.4 → 0.9.6

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: 0f96270f4b2465c3dadb499841094e20c27c1e6512e18f69c2e2f7dbb2c6559f
4
- data.tar.gz: 0aff913041f3105b4569c41c88918439b50754431e24c85e51197f851fd436d9
3
+ metadata.gz: e2852f618ab2af5a4076ece7807b931168d061345496397dfc3f409faf25da10
4
+ data.tar.gz: 53bc6f370b519b0c72eedbf0bac1fb742fd5288b3696e93c080137531e8eedb7
5
5
  SHA512:
6
- metadata.gz: 2505aeb0d16fc4a7786b24bbd1dcd607e7ac71122738ddc0bff9c6880d813813c5e6f2c134977a4bbf92b765565b8899822ae5d66dfe1977f214b89960ed6733
7
- data.tar.gz: 49b7a640acf6793d33a504b45c4f69b30767a86d95fe4f7fd3236c26e4494456ccf4e799effe12c856b85a2ab675fa9887664c934bdcdd93710381466af27145
6
+ metadata.gz: 8cc1a99558914d4c968db056cfbd2aa50835ecbc22dc981e54f4eee24c95dbc9fc0e4c7e7c2378c4fe96672255ce54ae9ce91d6d3e3a6e1f2fe96a7d4a26b3fb
7
+ data.tar.gz: 420f07f1d3a5145dedfdfb1d65fe8cf83c979bcd99283e48437b2291e04007e3532ea3ea5ff53a9cc8a35fb155989989d0bac094fc8051e9d083641a83a54ff1
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.4
1
+ 0.9.6
data/lib/oydid/didcomm.rb CHANGED
@@ -3,6 +3,11 @@
3
3
 
4
4
  class Oydid
5
5
 
6
+ # jwt-eddsa normalises the algorithm name to 'EdDSA' in the JOSE header, but
7
+ # dcsm writes an explicit 'ED25519' header. Accept both spellings of the same
8
+ # Ed25519 signature algorithm when verifying.
9
+ ED25519_ALGS = ['EdDSA', 'ED25519'].freeze
10
+
6
11
  # DIDComm Plain Message ---------------------
7
12
  def self.dcpm(payload, options)
8
13
  dcDoc = {}
@@ -24,7 +29,9 @@ class Oydid
24
29
  code, length, digest = multi_decode(private_key_encoded).first.unpack('SCa*')
25
30
  case Multicodecs[code].name
26
31
  when 'ed25519-priv'
27
- private_key = RbNaCl::Signatures::Ed25519::SigningKey.new(digest)
32
+ # jwt-eddsa signs with an Ed25519::SigningKey from the ed25519 gem;
33
+ # an RbNaCl signing key is rejected with JWT::EncodeError.
34
+ private_key = Ed25519::SigningKey.new(digest)
28
35
  token = JWT.encode payload, private_key, 'ED25519', { typ: 'JWM', kid: options[:sign_did].to_s, alg: 'ED25519' }
29
36
  else
30
37
  token = nil
@@ -33,18 +40,67 @@ class Oydid
33
40
  return [token, error]
34
41
  end
35
42
 
43
+ # w3c() mixes string keys at the top level with symbol keys inside a
44
+ # verification method, so read every attribute through this helper.
45
+ def self.dd_attr(hash, key)
46
+ return nil unless hash.is_a?(Hash)
47
+ hash[key].nil? ? hash[key.to_sym] : hash[key]
48
+ end
49
+
50
+ # The 'authentication' section of a DID document may embed a verification
51
+ # method or - which is how did:oyd resolves it - reference one by id. Only
52
+ # keys listed there may be used to authenticate the DID subject, so a
53
+ # document without an authentication section is rejected rather than
54
+ # silently falling back to some other key.
55
+ def self.authentication_key(didDocument)
56
+ verification_methods = dd_attr(didDocument, "verificationMethod") || []
57
+ entry = (dd_attr(didDocument, "authentication") || []).first
58
+ vm = case entry
59
+ when Hash then entry
60
+ when String then verification_methods.find { |v| dd_attr(v, "id").to_s == entry }
61
+ end
62
+ key = dd_attr(vm, "publicKeyMultibase")
63
+ if key.to_s == ""
64
+ return [nil, "no authentication key in DID document"]
65
+ end
66
+ return [key, ""]
67
+ end
68
+
69
+ # A DID may be written with or without the default location and with the
70
+ # location separator in either spelling, so compare the normalised forms.
71
+ # A fragment ("#key-doc") names a key within the DID, not another subject.
72
+ def self.same_did?(one, other)
73
+ percent_encode(one.to_s.split("#").first.to_s) ==
74
+ percent_encode(other.to_s.split("#").first.to_s)
75
+ end
76
+
77
+ # Verifying a token proves that whoever controls the DID named in its own
78
+ # 'kid' header signed it - not that the expected party did. Callers that
79
+ # read the result as an authorisation have to say which DID they expect,
80
+ # which is what options[:expect_did] is for. Checked before resolving, so a
81
+ # token from a foreign DID costs no network round trip.
36
82
  def self.dcsm_verify(token, options)
37
83
  error = ""
38
84
  decoded_payload = JWT.decode token, nil, false
39
85
  pubkey_did = decoded_payload.last["kid"]
86
+ if options[:expect_did].to_s != "" && !same_did?(pubkey_did, options[:expect_did])
87
+ return [nil, "token was signed by " + pubkey_did.to_s +
88
+ ", expected " + options[:expect_did].to_s]
89
+ end
40
90
  result, msg = Oydid.read(pubkey_did, options)
41
- public_key_encoded = Oydid.w3c(result, options)["authentication"].first["publicKeyMultibase"]
91
+ if result.nil?
92
+ return [nil, msg.to_s == "" ? "cannot resolve " + pubkey_did.to_s : msg.to_s]
93
+ end
94
+ public_key_encoded, error = authentication_key(Oydid.w3c(result, options))
95
+ if public_key_encoded.nil?
96
+ return [nil, error]
97
+ end
42
98
  begin
43
99
  code, length, digest = multi_decode(public_key_encoded).first.unpack('CCa*')
44
100
  case Multicodecs[code].name
45
101
  when 'ed25519-pub'
46
- public_key = RbNaCl::Signatures::Ed25519::VerifyKey.new(digest)
47
- payload = JWT.decode token.to_s, public_key, true, { algorithm: 'ED25519' }
102
+ public_key = Ed25519::VerifyKey.new(digest)
103
+ payload = JWT.decode token.to_s, public_key, true, { algorithms: ED25519_ALGS }
48
104
  else
49
105
  payload = nil
50
106
  error = "unsupported key codec"
@@ -61,7 +117,7 @@ class Oydid
61
117
  code, length, digest = multi_decode(private_key_encoded).first.unpack('SCa*')
62
118
  case Multicodecs[code].name
63
119
  when 'ed25519-priv'
64
- private_key = RbNaCl::Signatures::Ed25519::SigningKey.new(digest)
120
+ private_key = Ed25519::SigningKey.new(digest)
65
121
  token = JWT.encode payload, private_key, 'ED25519'
66
122
  when 'p256-priv'
67
123
  group = OpenSSL::PKey::EC::Group.new('prime256v1')
@@ -98,8 +154,8 @@ class Oydid
98
154
  code, length, digest = Oydid.multi_decode(public_key_encoded).first.unpack('CCa*')
99
155
  case Multicodecs[code].name
100
156
  when 'ed25519-pub'
101
- public_key = RbNaCl::Signatures::Ed25519::VerifyKey.new(digest)
102
- payload = JWT.decode token.to_s, public_key, true, { algorithm: 'ED25519' }
157
+ public_key = Ed25519::VerifyKey.new(digest)
158
+ payload = JWT.decode token.to_s, public_key, true, { algorithms: ED25519_ALGS }
103
159
  else
104
160
  payload = nil
105
161
  error = "unsupported key codec"
@@ -108,12 +164,24 @@ class Oydid
108
164
  end
109
165
 
110
166
  # signing for JWS ---------------------------
167
+ # An empty HMAC key is no key at all: OpenSSL::HMAC.digest('SHA256', '', data)
168
+ # returns a valid digest, so anyone can recompute the signature. jwt >= 3.2.0
169
+ # refuses it (CVE-2026-45363), earlier versions happily accepted forged
170
+ # tokens - most notably via the CLI, where a missing --hmac_secret arrives
171
+ # here as "". Reject it ourselves so the behaviour does not depend on which
172
+ # jwt version a consumer resolves.
111
173
  def self.msg_sign(payload, hmac_secret)
174
+ if hmac_secret.to_s == ""
175
+ return [nil, "HMAC secret must not be empty"]
176
+ end
112
177
  token = JWT.encode payload, hmac_secret, 'HS256'
113
178
  return [token, ""]
114
179
  end
115
180
 
116
181
  def self.msg_verify_jws(token, hmac_secret)
182
+ if hmac_secret.to_s == ""
183
+ return [nil, "HMAC secret must not be empty"]
184
+ end
117
185
  begin
118
186
  decoded_token = JWT.decode token, hmac_secret, true, { algorithm: 'HS256' }
119
187
  return [decoded_token, ""]
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 = []
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
@@ -646,6 +646,37 @@ describe "OYDID handling" do
646
646
  end
647
647
  end
648
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
+
649
680
  # a broken repository (5xx) has to stay distinguishable from a DID that is
650
681
  # not stored there - otherwise the caller reports "not found" and thereby
651
682
  # claims the identifier never existed
@@ -1155,4 +1186,116 @@ describe "OYDID handling" do
1155
1186
  end
1156
1187
  end
1157
1188
  end
1189
+ # These four defects together made the whole DIDComm branch unusable and, in
1190
+ # the HMAC case, forgeable. Nothing in the suite touched it before.
1191
+ describe "DIDComm signing" do
1192
+ let(:priv) { Oydid.generate_private_key("didcomm-spec-pwd", "ed25519-priv", {}).first }
1193
+ let(:pub) { Oydid.public_key(priv, {}).first }
1194
+
1195
+ # jwt < 3.2.0 verified an HS256 token against an empty key, so anybody could
1196
+ # forge one (CVE-2026-45363). The CLI reaches here with "" whenever
1197
+ # --hmac_secret is omitted, so the gem refuses the empty key itself.
1198
+ it "refuses to sign with an empty HMAC secret" do
1199
+ token, msg = Oydid.msg_sign({ "a" => 1 }, "")
1200
+
1201
+ expect(token).to be_nil
1202
+ expect(msg).to eq("HMAC secret must not be empty")
1203
+ end
1204
+
1205
+ it "refuses to verify a token forged with an empty HMAC key" do
1206
+ header = Base64.urlsafe_encode64('{"alg":"HS256"}').delete("=")
1207
+ payload = Base64.urlsafe_encode64('{"sub":"attacker"}').delete("=")
1208
+ digest = OpenSSL::HMAC.digest("SHA256", "", "#{header}.#{payload}")
1209
+ forged = "#{header}.#{payload}.#{Base64.urlsafe_encode64(digest).delete('=')}"
1210
+
1211
+ decoded, msg = Oydid.msg_verify_jws(forged, "")
1212
+
1213
+ expect(decoded).to be_nil
1214
+ expect(msg).to eq("HMAC secret must not be empty")
1215
+ end
1216
+
1217
+ it "still round-trips an HMAC signature with a real secret" do
1218
+ token, = Oydid.msg_sign({ "a" => 1 }, "s3cr3t")
1219
+ decoded, msg = Oydid.msg_verify_jws(token, "s3cr3t")
1220
+
1221
+ expect(msg).to eq("")
1222
+ expect(decoded.first).to eq({ "a" => 1 })
1223
+ end
1224
+
1225
+ # jwt-eddsa signs with Ed25519::SigningKey; handing it the RbNaCl key raised
1226
+ # JWT::EncodeError, so "oydid jws" could not produce a token at all.
1227
+ it "signs a DIDComm message with the Ed25519 document key" do
1228
+ token, msg = Oydid.dcsm({ "a" => 1 }, priv, { sign_did: "did:oyd:zSpec" })
1229
+
1230
+ expect(msg).to eq("")
1231
+ _, _, digest = Oydid.multi_decode(pub).first.unpack("CCa*")
1232
+ decoded = JWT.decode(token, Ed25519::VerifyKey.new(digest), true,
1233
+ { algorithms: Oydid::ED25519_ALGS })
1234
+ expect(decoded.first).to eq({ "a" => 1 })
1235
+ expect(decoded.last["kid"]).to eq("did:oyd:zSpec")
1236
+ end
1237
+
1238
+ # w3c() lists "authentication" as a reference and uses symbol keys inside a
1239
+ # verification method - the old code indexed a String with "publicKeyMultibase".
1240
+ describe "authentication_key" do
1241
+ let(:didDocument) do
1242
+ { "authentication" => ["did:oyd:zSpec#key-doc"],
1243
+ "verificationMethod" => [
1244
+ { id: "did:oyd:zSpec#key-rev", publicKeyMultibase: "z6MkRev" },
1245
+ { id: "did:oyd:zSpec#key-doc", publicKeyMultibase: "z6MkDoc" }
1246
+ ] }
1247
+ end
1248
+
1249
+ it "dereferences a referenced verification method" do
1250
+ key, msg = Oydid.authentication_key(didDocument)
1251
+
1252
+ expect(key).to eq("z6MkDoc")
1253
+ expect(msg).to eq("")
1254
+ end
1255
+
1256
+ it "accepts an embedded verification method" do
1257
+ key, = Oydid.authentication_key(
1258
+ { "authentication" => [{ "publicKeyMultibase" => "z6MkEmbedded" }] })
1259
+
1260
+ expect(key).to eq("z6MkEmbedded")
1261
+ end
1262
+
1263
+ # a DID created without --authentication has no such section; report it
1264
+ # instead of raising NoMethodError on nil
1265
+ it "reports a document without an authentication section" do
1266
+ key, msg = Oydid.authentication_key(didDocument.reject { |k, _| k == "authentication" })
1267
+
1268
+ expect(key).to be_nil
1269
+ expect(msg).to eq("no authentication key in DID document")
1270
+ end
1271
+ end
1272
+
1273
+ # dcsm_verify takes the public key from the token's own kid header, so a
1274
+ # green result on its own says nothing about *who* signed. --expect-did is
1275
+ # what turns it into an authorisation check.
1276
+ describe "expect_did" do
1277
+ let(:token) { Oydid.dcsm({ "a" => 1 }, priv, { sign_did: "did:oyd:zSigner" }).first }
1278
+
1279
+ it "treats the two spellings of the location separator as one DID" do
1280
+ expect(Oydid.same_did?("did:oyd:zAbc%40example.com", "did:oyd:zAbc@example.com")).to be true
1281
+ end
1282
+
1283
+ it "ignores a key fragment" do
1284
+ expect(Oydid.same_did?("did:oyd:zAbc#key-doc", "did:oyd:zAbc")).to be true
1285
+ end
1286
+
1287
+ it "separates different DIDs" do
1288
+ expect(Oydid.same_did?("did:oyd:zAbc", "did:oyd:zDef")).to be false
1289
+ end
1290
+
1291
+ # webmock lets no unstubbed request through, so this also shows that the
1292
+ # mismatch is caught before the DID is resolved
1293
+ it "refuses a token signed by another DID without resolving it" do
1294
+ payload, msg = Oydid.dcsm_verify(token, { expect_did: "did:oyd:zSomeoneElse" })
1295
+
1296
+ expect(payload).to be_nil
1297
+ expect(msg).to eq("token was signed by did:oyd:zSigner, expected did:oyd:zSomeoneElse")
1298
+ end
1299
+ end
1300
+ end
1158
1301
  end
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
4
+ version: 0.9.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christoph Fabianek
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-09-01 00:00:00.000000000 Z
10
+ date: 2026-09-04 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: simple_dag
@@ -41,16 +41,22 @@ dependencies:
41
41
  name: jwt
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
- - - "~>"
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '3.2'
47
+ - - "<"
45
48
  - !ruby/object:Gem::Version
46
- version: 3.1.2
49
+ version: '4'
47
50
  type: :runtime
48
51
  prerelease: false
49
52
  version_requirements: !ruby/object:Gem::Requirement
50
53
  requirements:
51
- - - "~>"
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '3.2'
57
+ - - "<"
52
58
  - !ruby/object:Gem::Version
53
- version: 3.1.2
59
+ version: '4'
54
60
  - !ruby/object:Gem::Dependency
55
61
  name: jwt-eddsa
56
62
  requirement: !ruby/object:Gem::Requirement
@@ -97,16 +103,22 @@ dependencies:
97
103
  name: json
98
104
  requirement: !ruby/object:Gem::Requirement
99
105
  requirements:
100
- - - "~>"
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: 2.19.9
109
+ - - "<"
101
110
  - !ruby/object:Gem::Version
102
- version: 2.18.1
111
+ version: '3'
103
112
  type: :runtime
104
113
  prerelease: false
105
114
  version_requirements: !ruby/object:Gem::Requirement
106
115
  requirements:
107
- - - "~>"
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: 2.19.9
119
+ - - "<"
108
120
  - !ruby/object:Gem::Version
109
- version: 2.18.1
121
+ version: '3'
110
122
  - !ruby/object:Gem::Dependency
111
123
  name: json-ld
112
124
  requirement: !ruby/object:Gem::Requirement