oydid 0.9.5 → 0.9.7

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: 3aa2cc9a77716310c41f0bda9dae80ca4939602b778384c79d874e63e8a27000
4
- data.tar.gz: cef9d037771c87c0bb4fb3614696c53c6a4b9d358f016f074defc37d88c810bd
3
+ metadata.gz: e80efb72fffc8f0a7e8ec1abb8a28376dad2f3463f581bd97719044d32f1f806
4
+ data.tar.gz: ac2fad7ca40541d40ec8b616704bc2225e4ebe144641805347fc7716682cdc8b
5
5
  SHA512:
6
- metadata.gz: 43b1ef66a0a911c316a9c0a20d5018595ece344545faf4b986040210b10245f3557da15c5ade1681402ad816d918c75fd788fae5d788a5ba8a2469a39e5b7ee1
7
- data.tar.gz: c08146faf1769b6f782e50bbd23468629e7a9069c9e20e9c6bee90cf0dce9a68f494aeceeb57b23d972bd331a73c1643a58bcac91cb73df2238eff33df0061e5
6
+ metadata.gz: d0886bb19bc142e297bc454117fec9c904e4ad4ad9b8fec49e359c1824333af1fee5a086dfb05788415eadfc7cb1e16a720ec7bca35bcd58dce2799aded1d4f8
7
+ data.tar.gz: 1e6d067d6f0c7fcf0a824965b253e4f98108efbb7356775e25a46954c3d37e94bf65c34d9295127fd6c61be9a2bf010e09a997f8c25524b6255f67b49621069e
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.5
1
+ 0.9.7
data/lib/oydid/basic.rb CHANGED
@@ -1272,6 +1272,29 @@ class Oydid
1272
1272
  end
1273
1273
  end
1274
1274
 
1275
+ # Where a document would be written: the explicit doc_location, the generic
1276
+ # location, or the default repository - the same fallbacks publish uses.
1277
+ def self.write_location(options)
1278
+ loc = options[:doc_location].to_s
1279
+ loc = options[:location].to_s if loc == ""
1280
+ loc = DEFAULT_LOCATION if loc == ""
1281
+ loc
1282
+ end
1283
+
1284
+ # Ask a repository whether a public key already controls an active DID.
1285
+ # Only a clear "yes" counts: a repository that does not know the endpoint
1286
+ # (404), a non-HTTP location or an unreachable host must not block a create -
1287
+ # the write path enforces the rule in any case, this is the early exit.
1288
+ def self.key_in_active_use?(public_key, location, options = {})
1289
+ return false if public_key.to_s == ""
1290
+ return false unless location.to_s.start_with?("http")
1291
+ retVal = HTTParty.get(location.to_s + "/key/" + public_key.to_s)
1292
+ return false if retVal.code != 200
1293
+ retVal.parsed_response["active"] == true
1294
+ rescue StandardError
1295
+ false
1296
+ end
1297
+
1275
1298
  def self.retrieve_document(doc_identifier, doc_file, doc_location, options)
1276
1299
  # in-process callers can supply the DID document directly (e.g. read from
1277
1300
  # a local database) to avoid any HTTP/file lookup
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/vc.rb CHANGED
@@ -58,7 +58,7 @@ class Oydid
58
58
  if retVal.code == 401
59
59
  msg = "unauthorized (valid Bearer token required)"
60
60
  else
61
- msg = retVal.parsed_response("error").to_s rescue "invalid response from " + vc_url.to_s
61
+ msg = http_error_message(retVal, vc_url)
62
62
  end
63
63
  return [nil, msg]
64
64
  end
@@ -338,7 +338,7 @@ class Oydid
338
338
  headers: { 'Content-Type' => 'application/json' },
339
339
  body: vc_data.to_json )
340
340
  if retVal.code != 200
341
- err_msg = retVal.parsed_response("error").to_s rescue "invalid response from " + vc_url.to_s
341
+ err_msg = http_error_message(retVal, vc_url)
342
342
  return [nil, err_msg]
343
343
  end
344
344
  return [retVal["identifier"], ""]
@@ -355,7 +355,7 @@ class Oydid
355
355
  vp_url = vp_location.sub(/(\/)+$/,'') + "/presentations/" + identifier
356
356
  retVal = HTTParty.get(vp_url)
357
357
  if retVal.code != 200
358
- msg = retVal.parsed_response("error").to_s rescue "invalid response from " + vp_url.to_s
358
+ msg = http_error_message(retVal, vp_url)
359
359
  return [nil, msg]
360
360
  end
361
361
  return [retVal.parsed_response, ""]
@@ -451,7 +451,7 @@ class Oydid
451
451
  headers: { 'Content-Type' => 'application/json' },
452
452
  body: vp_data.to_json )
453
453
  if retVal.code != 200
454
- err_msg = retVal.parsed_response("error").to_s rescue "invalid response from " + vp_url.to_s
454
+ err_msg = http_error_message(retVal, vp_url)
455
455
  return [nil, err_msg]
456
456
  end
457
457
  return [vp["identifier"], ""]
data/lib/oydid.rb CHANGED
@@ -39,6 +39,12 @@ class Oydid
39
39
  JWS_SECURITY_SUITE = "https://w3id.org/security/suites/jws-2020/v1"
40
40
  DEFAULT_PUBLIC_RESOLVER = "https://dev.uniresolver.io/1.0/identifiers/"
41
41
 
42
+ # A public key controls at most one active DID at a time. The repository
43
+ # enforces this when a document is written; the constant lives here so every
44
+ # caller (repository, registrar driver, CLI) can recognise the rejection by
45
+ # comparison instead of searching the message text.
46
+ KEY_IN_USE_ERROR = "public key already controls an active DID"
47
+
42
48
  # Single-byte multicodec codes for the intermediate BLAKE2b digest sizes
43
49
  # (17-23 bytes), needed to keep a did:oyd identifier short enough for the
44
50
  # 50 character URL limit of the EU Digital Product Passport registry.
@@ -278,6 +284,25 @@ class Oydid
278
284
  else
279
285
  return [nil, nil, nil, "CMSM accepts at most two public keys"]
280
286
  end
287
+
288
+ # Reject a reused document key before the client signs anything.
289
+ # The repository enforces the rule when the document is written,
290
+ # but a CMSM flow only writes after three signatures - with a
291
+ # secure element that is three round trips to hardware for a
292
+ # request that cannot succeed. A repository that does not know
293
+ # the endpoint answers 404 and the flow continues as before.
294
+ #
295
+ # Only for create: a non-rotating UPDATE legitimately carries
296
+ # the document key of the version it replaces, and that is the
297
+ # most common form of update.
298
+ #
299
+ # skip_publish means the caller is the repository itself: its own
300
+ # database, not the one at doc_location, decides - it checks in
301
+ # its CMSM controller before the flow starts.
302
+ if mode.to_s == "create" && !options[:skip_publish] &&
303
+ key_in_active_use?(publicKey, write_location(options), options)
304
+ return [nil, nil, nil, KEY_IN_USE_ERROR]
305
+ end
281
306
  else
282
307
  # continue a persisted flow: the request carries the session and
283
308
  # exactly one new signature, which fills the next open slot
@@ -731,7 +756,7 @@ class Oydid
731
756
  headers: { 'Content-Type' => 'application/json' },
732
757
  body: did_data.to_json )
733
758
  if retVal.code != 200
734
- err_msg = retVal.parsed_response("error").to_s rescue "invalid response from " + doc_location.to_s + "/doc"
759
+ err_msg = http_error_message(retVal, doc_location.to_s + "/doc")
735
760
  return [false, err_msg]
736
761
  end
737
762
  else
@@ -894,7 +919,7 @@ class Oydid
894
919
  headers: { 'Content-Type' => 'application/json' },
895
920
  body: my_body.to_json )
896
921
  if retVal.code != 200
897
- err_msg = retVal.parsed_response("error").to_s rescue "invalid response from " + doc_location.to_s + "/cmsm"
922
+ err_msg = http_error_message(retVal, doc_location.to_s + "/cmsm")
898
923
  return [nil, err_msg]
899
924
  end
900
925
  else
@@ -1084,7 +1109,7 @@ class Oydid
1084
1109
  body: {"log": log}.to_json )
1085
1110
  code = retVal.code rescue 500
1086
1111
  if code != 200
1087
- err_msg = retVal.parsed_response["error"].to_s rescue "invalid response from " + source_location.to_s + "/log"
1112
+ err_msg = http_error_message(retVal, source_location.to_s + "/log")
1088
1113
  return ["", err_msg]
1089
1114
  end
1090
1115
  log_hash = retVal.parsed_response["log"] rescue ""
@@ -1306,7 +1331,7 @@ class Oydid
1306
1331
  headers: { 'Content-Type' => 'application/json' },
1307
1332
  body: {"log": revoc_log}.to_json )
1308
1333
  if retVal.code != 200
1309
- msg = retVal.parsed_response("error").to_s rescue "invalid response from " + doc_location.to_s + "/log/" + did_hash.to_s
1334
+ msg = http_error_message(retVal, doc_location.to_s + "/log/" + did_hash.to_s)
1310
1335
  return [nil, msg]
1311
1336
  end
1312
1337
  else
data/spec/oydid_spec.rb CHANGED
@@ -701,6 +701,39 @@ describe "OYDID handling" do
701
701
  expect(msg).to start_with("invalid response from")
702
702
  expect(Oydid.upstream_error?(msg)).to be false
703
703
  end
704
+
705
+ # Only the reading paths used the helper. The writing ones built their
706
+ # message with parsed_response("error") - round brackets, so an
707
+ # ArgumentError, so the surrounding rescue swallowed whatever the
708
+ # repository had said. A rejected create then read as "invalid response
709
+ # from .../doc" and the actual reason never reached the caller.
710
+ it "passes the repository's error through when writing a DID" do
711
+ stub_request(:post, "https://oydid.ownyourdata.eu/doc").to_return(
712
+ status: 400,
713
+ headers: { "Content-Type" => "application/json" },
714
+ body: { "error" => "public key already controls an active DID" }.to_json)
715
+
716
+ retVal, msg = Oydid.create({ "id" => "spec" },
717
+ { doc_pwd: "spec-doc-pwd", rev_pwd: "spec-rev-pwd",
718
+ location: "https://oydid.ownyourdata.eu",
719
+ digest: "sha2-256", encode: "base58btc" })
720
+
721
+ expect(retVal).to be_nil
722
+ expect(msg).to eq("public key already controls an active DID")
723
+ end
724
+
725
+ it "marks a 5xx while writing as an upstream error" do
726
+ stub_request(:post, "https://oydid.ownyourdata.eu/doc").to_return(status: 502, body: "")
727
+
728
+ retVal, msg = Oydid.create({ "id" => "spec" },
729
+ { doc_pwd: "spec-doc-pwd", rev_pwd: "spec-rev-pwd",
730
+ location: "https://oydid.ownyourdata.eu",
731
+ digest: "sha2-256", encode: "base58btc" })
732
+
733
+ expect(retVal).to be_nil
734
+ expect(Oydid.upstream_error?(msg)).to be true
735
+ expect(msg).to include("502")
736
+ end
704
737
  end
705
738
 
706
739
  # main functionds
@@ -1114,6 +1147,79 @@ describe "OYDID handling" do
1114
1147
  status.transform_keys(&:to_s)
1115
1148
  end
1116
1149
 
1150
+ # The write path rejects a reused document key, but a CMSM flow only writes
1151
+ # after three signatures. With a secure element those are three round trips
1152
+ # to hardware for a request that cannot succeed, so phase 1 asks first.
1153
+ describe "document key already in use" do
1154
+ let(:key_url) { "https://oydid.ownyourdata.eu/key/" + pub }
1155
+
1156
+ def remote_options(extra = {})
1157
+ cmsm_options({ skip_publish: false,
1158
+ location: "https://oydid.ownyourdata.eu" }.merge(extra))
1159
+ end
1160
+
1161
+ it "stops phase 1 when the repository reports the key as active" do
1162
+ stub_request(:get, key_url).to_return(
1163
+ status: 200,
1164
+ headers: { "Content-Type" => "application/json" },
1165
+ body: { "active" => true, "did" => "zQmSpec" }.to_json)
1166
+
1167
+ status, msg = Oydid.create({ "key" => pub }, remote_options)
1168
+
1169
+ expect(status).to be_nil
1170
+ expect(msg).to eq(Oydid::KEY_IN_USE_ERROR)
1171
+ end
1172
+
1173
+ it "continues when the key controls only revoked DIDs" do
1174
+ stub_request(:get, key_url).to_return(
1175
+ status: 200,
1176
+ headers: { "Content-Type" => "application/json" },
1177
+ body: { "active" => false }.to_json)
1178
+
1179
+ status, msg = Oydid.create({ "key" => pub }, remote_options)
1180
+
1181
+ expect(msg).to eq("cmsm")
1182
+ expect(status.transform_keys(&:to_s)["with"]).to eq("key-doc")
1183
+ end
1184
+
1185
+ # A repository that predates the endpoint answers 404. That must not stop
1186
+ # a create - the write path still enforces the rule.
1187
+ it "continues when the repository does not know the endpoint" do
1188
+ stub_request(:get, key_url).to_return(status: 404, body: "")
1189
+
1190
+ status, msg = Oydid.create({ "key" => pub }, remote_options)
1191
+
1192
+ expect(msg).to eq("cmsm")
1193
+ end
1194
+
1195
+ # A non-rotating update legitimately carries the document key of the
1196
+ # version it replaces - the most common form of update. The guardrail is
1197
+ # about create only, so the update path must not even ask.
1198
+ it "asks nobody on update" do
1199
+ stub_request(:get, key_url).to_return(
1200
+ status: 200,
1201
+ headers: { "Content-Type" => "application/json" },
1202
+ body: { "active" => true }.to_json)
1203
+ stub_request(:get, %r{\Ahttps://oydid\.ownyourdata\.eu/doc/}).to_return(status: 404, body: "")
1204
+
1205
+ status, msg = Oydid.update({ "key" => pub }, "did:oyd:zQmSpecUnknownDid", remote_options)
1206
+
1207
+ expect(status).to be_nil
1208
+ expect(msg).not_to eq(Oydid::KEY_IN_USE_ERROR)
1209
+ expect(a_request(:get, key_url)).not_to have_been_made
1210
+ end
1211
+
1212
+ # skip_publish means the repository itself is calling: its own database
1213
+ # decides, and asking the public repository about a local key would be
1214
+ # both wrong and a needless request.
1215
+ it "asks nobody when the caller stores the DID itself" do
1216
+ status, msg = Oydid.create({ "key" => pub }, cmsm_options)
1217
+
1218
+ expect(msg).to eq("cmsm")
1219
+ expect(a_request(:get, %r{/key/})).not_to have_been_made
1220
+ end
1221
+ end
1222
+
1117
1223
  describe "cmsm_verify_signature" do
1118
1224
  it "accepts a signature made with the named key" do
1119
1225
  signature = Oydid.sign("hello", priv, {}).first
@@ -1186,4 +1292,116 @@ describe "OYDID handling" do
1186
1292
  end
1187
1293
  end
1188
1294
  end
1295
+ # These four defects together made the whole DIDComm branch unusable and, in
1296
+ # the HMAC case, forgeable. Nothing in the suite touched it before.
1297
+ describe "DIDComm signing" do
1298
+ let(:priv) { Oydid.generate_private_key("didcomm-spec-pwd", "ed25519-priv", {}).first }
1299
+ let(:pub) { Oydid.public_key(priv, {}).first }
1300
+
1301
+ # jwt < 3.2.0 verified an HS256 token against an empty key, so anybody could
1302
+ # forge one (CVE-2026-45363). The CLI reaches here with "" whenever
1303
+ # --hmac_secret is omitted, so the gem refuses the empty key itself.
1304
+ it "refuses to sign with an empty HMAC secret" do
1305
+ token, msg = Oydid.msg_sign({ "a" => 1 }, "")
1306
+
1307
+ expect(token).to be_nil
1308
+ expect(msg).to eq("HMAC secret must not be empty")
1309
+ end
1310
+
1311
+ it "refuses to verify a token forged with an empty HMAC key" do
1312
+ header = Base64.urlsafe_encode64('{"alg":"HS256"}').delete("=")
1313
+ payload = Base64.urlsafe_encode64('{"sub":"attacker"}').delete("=")
1314
+ digest = OpenSSL::HMAC.digest("SHA256", "", "#{header}.#{payload}")
1315
+ forged = "#{header}.#{payload}.#{Base64.urlsafe_encode64(digest).delete('=')}"
1316
+
1317
+ decoded, msg = Oydid.msg_verify_jws(forged, "")
1318
+
1319
+ expect(decoded).to be_nil
1320
+ expect(msg).to eq("HMAC secret must not be empty")
1321
+ end
1322
+
1323
+ it "still round-trips an HMAC signature with a real secret" do
1324
+ token, = Oydid.msg_sign({ "a" => 1 }, "s3cr3t")
1325
+ decoded, msg = Oydid.msg_verify_jws(token, "s3cr3t")
1326
+
1327
+ expect(msg).to eq("")
1328
+ expect(decoded.first).to eq({ "a" => 1 })
1329
+ end
1330
+
1331
+ # jwt-eddsa signs with Ed25519::SigningKey; handing it the RbNaCl key raised
1332
+ # JWT::EncodeError, so "oydid jws" could not produce a token at all.
1333
+ it "signs a DIDComm message with the Ed25519 document key" do
1334
+ token, msg = Oydid.dcsm({ "a" => 1 }, priv, { sign_did: "did:oyd:zSpec" })
1335
+
1336
+ expect(msg).to eq("")
1337
+ _, _, digest = Oydid.multi_decode(pub).first.unpack("CCa*")
1338
+ decoded = JWT.decode(token, Ed25519::VerifyKey.new(digest), true,
1339
+ { algorithms: Oydid::ED25519_ALGS })
1340
+ expect(decoded.first).to eq({ "a" => 1 })
1341
+ expect(decoded.last["kid"]).to eq("did:oyd:zSpec")
1342
+ end
1343
+
1344
+ # w3c() lists "authentication" as a reference and uses symbol keys inside a
1345
+ # verification method - the old code indexed a String with "publicKeyMultibase".
1346
+ describe "authentication_key" do
1347
+ let(:didDocument) do
1348
+ { "authentication" => ["did:oyd:zSpec#key-doc"],
1349
+ "verificationMethod" => [
1350
+ { id: "did:oyd:zSpec#key-rev", publicKeyMultibase: "z6MkRev" },
1351
+ { id: "did:oyd:zSpec#key-doc", publicKeyMultibase: "z6MkDoc" }
1352
+ ] }
1353
+ end
1354
+
1355
+ it "dereferences a referenced verification method" do
1356
+ key, msg = Oydid.authentication_key(didDocument)
1357
+
1358
+ expect(key).to eq("z6MkDoc")
1359
+ expect(msg).to eq("")
1360
+ end
1361
+
1362
+ it "accepts an embedded verification method" do
1363
+ key, = Oydid.authentication_key(
1364
+ { "authentication" => [{ "publicKeyMultibase" => "z6MkEmbedded" }] })
1365
+
1366
+ expect(key).to eq("z6MkEmbedded")
1367
+ end
1368
+
1369
+ # a DID created without --authentication has no such section; report it
1370
+ # instead of raising NoMethodError on nil
1371
+ it "reports a document without an authentication section" do
1372
+ key, msg = Oydid.authentication_key(didDocument.reject { |k, _| k == "authentication" })
1373
+
1374
+ expect(key).to be_nil
1375
+ expect(msg).to eq("no authentication key in DID document")
1376
+ end
1377
+ end
1378
+
1379
+ # dcsm_verify takes the public key from the token's own kid header, so a
1380
+ # green result on its own says nothing about *who* signed. --expect-did is
1381
+ # what turns it into an authorisation check.
1382
+ describe "expect_did" do
1383
+ let(:token) { Oydid.dcsm({ "a" => 1 }, priv, { sign_did: "did:oyd:zSigner" }).first }
1384
+
1385
+ it "treats the two spellings of the location separator as one DID" do
1386
+ expect(Oydid.same_did?("did:oyd:zAbc%40example.com", "did:oyd:zAbc@example.com")).to be true
1387
+ end
1388
+
1389
+ it "ignores a key fragment" do
1390
+ expect(Oydid.same_did?("did:oyd:zAbc#key-doc", "did:oyd:zAbc")).to be true
1391
+ end
1392
+
1393
+ it "separates different DIDs" do
1394
+ expect(Oydid.same_did?("did:oyd:zAbc", "did:oyd:zDef")).to be false
1395
+ end
1396
+
1397
+ # webmock lets no unstubbed request through, so this also shows that the
1398
+ # mismatch is caught before the DID is resolved
1399
+ it "refuses a token signed by another DID without resolving it" do
1400
+ payload, msg = Oydid.dcsm_verify(token, { expect_did: "did:oyd:zSomeoneElse" })
1401
+
1402
+ expect(payload).to be_nil
1403
+ expect(msg).to eq("token was signed by did:oyd:zSigner, expected did:oyd:zSomeoneElse")
1404
+ end
1405
+ end
1406
+ end
1189
1407
  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.5
4
+ version: 0.9.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christoph Fabianek
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-09-02 00:00:00.000000000 Z
10
+ date: 2026-09-10 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