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 +4 -4
- data/VERSION +1 -1
- data/lib/oydid/basic.rb +23 -0
- data/lib/oydid/didcomm.rb +75 -7
- data/lib/oydid/vc.rb +4 -4
- data/lib/oydid.rb +29 -4
- data/spec/oydid_spec.rb +218 -0
- metadata +22 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e80efb72fffc8f0a7e8ec1abb8a28376dad2f3463f581bd97719044d32f1f806
|
|
4
|
+
data.tar.gz: ac2fad7ca40541d40ec8b616704bc2225e4ebe144641805347fc7716682cdc8b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d0886bb19bc142e297bc454117fec9c904e4ad4ad9b8fec49e359c1824333af1fee5a086dfb05788415eadfc7cb1e16a720ec7bca35bcd58dce2799aded1d4f8
|
|
7
|
+
data.tar.gz: 1e6d067d6f0c7fcf0a824965b253e4f98108efbb7356775e25a46954c3d37e94bf65c34d9295127fd6c61be9a2bf010e09a997f8c25524b6255f67b49621069e
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.9.
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
47
|
-
payload = JWT.decode token.to_s, public_key, true, {
|
|
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 =
|
|
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 =
|
|
102
|
-
payload = JWT.decode token.to_s, public_key, true, {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
121
|
+
version: '3'
|
|
110
122
|
- !ruby/object:Gem::Dependency
|
|
111
123
|
name: json-ld
|
|
112
124
|
requirement: !ruby/object:Gem::Requirement
|