oydid 0.9.0 → 0.9.2
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 +21 -3
- data/lib/oydid/log.rb +2 -2
- data/lib/oydid.rb +123 -13
- data/spec/oydid_spec.rb +286 -3
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 072b6ccce2a1b7bf3c607aa01d3a3c97cfd37f8e41d52bb9db529d539ccb6eda
|
|
4
|
+
data.tar.gz: 75ccf1a0357d60e629dac7ea3a046cfe0933693a04b5179bf0db7b54929abc6b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c936a18cf7106ae73e76fb30fc5ee789a50330a342dfa9624f29bd03574cde42b9c8a4b6a710e0f7e3ed9c6865fb8ec49aaac2cf7cd24c5d5330405e4f20cd81
|
|
7
|
+
data.tar.gz: 3d6c15c81d795cd470f75031722f211aac478dc239a1ece245a64e1b00144d7d37eadfe3c5db8dab4ebfd487e685011f50615f5c40d05ea502d59762cbb51a1f
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.9.
|
|
1
|
+
0.9.2
|
data/lib/oydid/basic.rb
CHANGED
|
@@ -338,7 +338,7 @@ class Oydid
|
|
|
338
338
|
return [nil, msg]
|
|
339
339
|
exit
|
|
340
340
|
end
|
|
341
|
-
pubKey = did_document["doc"]["key"].split(":")
|
|
341
|
+
pubKey = did_document["doc"]["key"].split(":")[0] rescue nil
|
|
342
342
|
if pubKey.nil?
|
|
343
343
|
return [nil, "cannot resolve " + did.to_s]
|
|
344
344
|
else
|
|
@@ -367,9 +367,9 @@ class Oydid
|
|
|
367
367
|
# get current public key
|
|
368
368
|
case key_type
|
|
369
369
|
when "doc"
|
|
370
|
-
keys = [did_document["doc"]["key"].split(":")
|
|
370
|
+
keys = [did_document["doc"]["key"].split(":")[0]] rescue nil
|
|
371
371
|
when "rev"
|
|
372
|
-
keys = [did_document["doc"]["key"].split(":")
|
|
372
|
+
keys = [did_document["doc"]["key"].split(":")[1]] rescue nil
|
|
373
373
|
else
|
|
374
374
|
return [nil, "invalid key type: " + key_type]
|
|
375
375
|
end
|
|
@@ -1216,6 +1216,24 @@ class Oydid
|
|
|
1216
1216
|
end
|
|
1217
1217
|
end
|
|
1218
1218
|
|
|
1219
|
+
# The identifier without its location suffix.
|
|
1220
|
+
#
|
|
1221
|
+
# A did:oyd can carry "@<location>" (raw or percent-encoded) to say where the
|
|
1222
|
+
# document is hosted. That is a resolution hint, not part of the identity:
|
|
1223
|
+
# the same document can be mirrored at any number of locations, so the set of
|
|
1224
|
+
# location-bound variants is open and cannot be enumerated. Everything that
|
|
1225
|
+
# states identity - canonicalId and equivalentId in the didDocumentMetadata -
|
|
1226
|
+
# therefore uses this location-free form. The location-bound variant stays in
|
|
1227
|
+
# alsoKnownAs.
|
|
1228
|
+
#
|
|
1229
|
+
# Note this must NOT be applied to the id of the DID document itself: DID Core
|
|
1230
|
+
# requires that "the value of the id property in the retrieved DID document
|
|
1231
|
+
# must always match the DID being resolved", so a location-bound DID that was
|
|
1232
|
+
# requested is echoed as requested (see Oydid.document_id).
|
|
1233
|
+
def self.strip_location(id)
|
|
1234
|
+
id.to_s.split(LOCATION_PREFIX).first.split(CGI.escape LOCATION_PREFIX).first rescue id.to_s
|
|
1235
|
+
end
|
|
1236
|
+
|
|
1219
1237
|
def self.get_location(id)
|
|
1220
1238
|
if id.include?(LOCATION_PREFIX)
|
|
1221
1239
|
id_split = id.split(LOCATION_PREFIX)
|
data/lib/oydid/log.rb
CHANGED
|
@@ -361,7 +361,7 @@ class Oydid
|
|
|
361
361
|
currentDID["verification"] += JSON.pretty_generate(doc) + "\n"
|
|
362
362
|
currentDID["verification"] += "(Details: https://ownyourdata.github.io/oydid/#calculate_hash)" + "\n\n"
|
|
363
363
|
end
|
|
364
|
-
current_public_doc_key = currentDID["doc"]["key"].split(":")
|
|
364
|
+
current_public_doc_key = currentDID["doc"]["key"].split(":")[0] rescue ""
|
|
365
365
|
|
|
366
366
|
when 0 # TERMINATE
|
|
367
367
|
currentDID["termination_log_id"] = i
|
|
@@ -506,7 +506,7 @@ class Oydid
|
|
|
506
506
|
return currentDID
|
|
507
507
|
end
|
|
508
508
|
next_doc = next_doc.first["doc"]
|
|
509
|
-
if pubKeys.include?(next_doc["key"].split(":")
|
|
509
|
+
if pubKeys.include?(next_doc["key"].split(":")[0])
|
|
510
510
|
currentDID["verification"] += "⚠️ no key rotation in updated DID Document" + "\n"
|
|
511
511
|
end
|
|
512
512
|
currentDID["verification"] += "\n"
|
data/lib/oydid.rb
CHANGED
|
@@ -383,8 +383,8 @@ class Oydid
|
|
|
383
383
|
old_privateKey = nil
|
|
384
384
|
old_revocationKey = nil
|
|
385
385
|
old_did_key = did_info["doc"]["key"].to_s
|
|
386
|
-
old_publicDocKey = old_did_key.split(":").
|
|
387
|
-
old_publicRevKey = old_did_key.split(":").
|
|
386
|
+
old_publicDocKey = old_did_key.split(":")[0].to_s
|
|
387
|
+
old_publicRevKey = old_did_key.split(":")[1].to_s
|
|
388
388
|
|
|
389
389
|
verified, vmsg = verify_revocation_log(did_info, options[:log_revoke_old],
|
|
390
390
|
"log_revoke_old", options)
|
|
@@ -460,6 +460,16 @@ class Oydid
|
|
|
460
460
|
publicKey = public_key(privateKey, options).first
|
|
461
461
|
pubRevoKey = public_key(revocationKey, options).first
|
|
462
462
|
end
|
|
463
|
+
# The "key" field is the control field of a did:oyd: it names who may
|
|
464
|
+
# update the document (slot 0) and who may revoke it (slot 1) - nothing
|
|
465
|
+
# else. Keys that are content rather than control (key agreement above
|
|
466
|
+
# all) belong in the payload as verification methods, where they are
|
|
467
|
+
# covered by the identifier hash just the same.
|
|
468
|
+
#
|
|
469
|
+
# The field is positional and has exactly two slots. Readers must use an
|
|
470
|
+
# explicit index; .last would silently return the wrong key if a third
|
|
471
|
+
# slot were ever appended, and the mistake would only surface at
|
|
472
|
+
# revocation time. A spec enforces this across the repository.
|
|
463
473
|
did_key = publicKey + ":" + pubRevoKey
|
|
464
474
|
|
|
465
475
|
# the document is assembled once, in the first phase of a CMSM flow -
|
|
@@ -512,6 +522,10 @@ class Oydid
|
|
|
512
522
|
end
|
|
513
523
|
|
|
514
524
|
if revocationKey.to_s == ""
|
|
525
|
+
if options[:cmsm]
|
|
526
|
+
err = cmsm_verify_signature(subDidHash, cmsm_sig_rev, pubRevoKey, "key-rev")
|
|
527
|
+
return [nil, nil, nil, err] if !err.nil?
|
|
528
|
+
end
|
|
515
529
|
signedSubDidHash = cmsm_sig_rev
|
|
516
530
|
else
|
|
517
531
|
signedSubDidHash = sign(subDidHash, revocationKey, LOG_HASH_OPTIONS).first
|
|
@@ -534,6 +548,8 @@ class Oydid
|
|
|
534
548
|
cmsm_sig_rev, cmsm_sig_doc, cmsm_sig_create,
|
|
535
549
|
did_old, cmsm_log_revoke_old), options)
|
|
536
550
|
end
|
|
551
|
+
err = cmsm_verify_signature(l2_doc, cmsm_sig_doc, publicKey, "key-doc")
|
|
552
|
+
return [nil, nil, nil, err] if !err.nil?
|
|
537
553
|
l2_sig = cmsm_sig_doc
|
|
538
554
|
else
|
|
539
555
|
l2_sig = sign(l2_doc, privateKey, options).first
|
|
@@ -595,6 +611,8 @@ class Oydid
|
|
|
595
611
|
cmsm_sig_rev, cmsm_sig_doc, cmsm_sig_create,
|
|
596
612
|
did_old, cmsm_log_revoke_old), options)
|
|
597
613
|
end
|
|
614
|
+
err = cmsm_verify_signature(l1_doc, cmsm_sig_create, old_publicDocKey, "key-doc-old")
|
|
615
|
+
return [nil, nil, nil, err] if !err.nil?
|
|
598
616
|
l1_sig = cmsm_sig_create
|
|
599
617
|
else
|
|
600
618
|
l1_sig = sign(l1_doc, old_privateKey, options).first
|
|
@@ -628,6 +646,8 @@ class Oydid
|
|
|
628
646
|
cmsm_sig_rev, cmsm_sig_doc, cmsm_sig_create,
|
|
629
647
|
did_old, cmsm_log_revoke_old), options)
|
|
630
648
|
end
|
|
649
|
+
err = cmsm_verify_signature(l1_doc, cmsm_sig_create, publicKey, "key-doc")
|
|
650
|
+
return [nil, nil, nil, err] if !err.nil?
|
|
631
651
|
l1_sig = cmsm_sig_create
|
|
632
652
|
else
|
|
633
653
|
l1_sig = sign(l1_doc, privateKey, options).first
|
|
@@ -736,7 +756,7 @@ class Oydid
|
|
|
736
756
|
end
|
|
737
757
|
|
|
738
758
|
did_key = did_info["doc"]["key"].to_s
|
|
739
|
-
pubRevKey = did_key.split(":").
|
|
759
|
+
pubRevKey = did_key.split(":")[1].to_s
|
|
740
760
|
subDid = {"doc": did_info["doc"]["doc"], "key": did_key}.to_json
|
|
741
761
|
subDidHash = multi_hash(canonical(subDid), LOG_HASH_OPTIONS).first
|
|
742
762
|
|
|
@@ -776,6 +796,23 @@ class Oydid
|
|
|
776
796
|
# state of a CMSM flow that has to survive between phases. Deliberately only
|
|
777
797
|
# inputs and collected signatures - no derived hashes: every phase recomputes
|
|
778
798
|
# r1 / l2_doc / the DID from these values, so there is nothing to keep in sync.
|
|
799
|
+
# A signature collected in a CMSM phase is only worth something if it was
|
|
800
|
+
# really made with the key the challenge named. Without this check the flow
|
|
801
|
+
# takes anything: the log entries are built from whatever the client sent and
|
|
802
|
+
# only fail on resolution - by which point the DID exists and has claimed the
|
|
803
|
+
# public key, which lets anyone permanently burn a key they do not hold.
|
|
804
|
+
#
|
|
805
|
+
# The message starts with "CMSM " so that the REST layers report it as a
|
|
806
|
+
# client error (400), not as a server fault.
|
|
807
|
+
def self.cmsm_verify_signature(value, signature, public_key, with)
|
|
808
|
+
if public_key.to_s == ""
|
|
809
|
+
return "CMSM cannot verify the " + with.to_s + " signature: no public key in session"
|
|
810
|
+
end
|
|
811
|
+
success, _msg = (verify(value, signature, public_key) rescue [false, ""])
|
|
812
|
+
return nil if success == true
|
|
813
|
+
"CMSM signature for " + with.to_s + " is invalid"
|
|
814
|
+
end
|
|
815
|
+
|
|
779
816
|
def self.cmsm_state(publicKey, pubRevoKey, revocationKey, did_doc, ts, doc_location, sig_rev, sig_doc, sig_create, did_old = nil, log_revoke_old = nil)
|
|
780
817
|
{
|
|
781
818
|
publicKey: publicKey,
|
|
@@ -1446,19 +1483,42 @@ class Oydid
|
|
|
1446
1483
|
# uniresolver plugin) and they had drifted into computing this list with
|
|
1447
1484
|
# different rules.
|
|
1448
1485
|
#
|
|
1486
|
+
# Both values are location-free: the "@<location>" suffix says where a
|
|
1487
|
+
# document is hosted, not who it is, and the same document can be mirrored at
|
|
1488
|
+
# any number of locations - so the set of location-bound variants is open and
|
|
1489
|
+
# equivalentId could not state it correctly anyway. The raw values these are
|
|
1490
|
+
# built from do carry a location (dag_update sets did_info["did"] from the log
|
|
1491
|
+
# entry, and log[]["doc"] is written with one), which is why strip_location is
|
|
1492
|
+
# applied here rather than assumed. The location-bound variant stays in
|
|
1493
|
+
# alsoKnownAs; document_id is deliberately left alone (see strip_location).
|
|
1494
|
+
#
|
|
1449
1495
|
# Returns [canonicalId, equivalentIds]; equivalentIds never contains the
|
|
1450
|
-
# identifier the DID document itself carries as `id` (see document_id)
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1496
|
+
# identifier the DID document itself carries as `id` (see document_id) and is
|
|
1497
|
+
# empty - not a set with the DID itself in it - while there is only one
|
|
1498
|
+
# version.
|
|
1499
|
+
# keep_location = true reproduces the pre-0.9.1 values, location suffix and
|
|
1500
|
+
# all. Only w3c uses it, to keep alsoKnownAs listing the location-bound
|
|
1501
|
+
# variant of a DID - dropping that would take the location out of the
|
|
1502
|
+
# document altogether, and alsoKnownAs is where it belongs.
|
|
1503
|
+
#
|
|
1504
|
+
# Deliberately a positional argument: callers pass did_info as a braceless
|
|
1505
|
+
# hash literal (version_ids("did" => ..., "log" => ...)), and a method with
|
|
1506
|
+
# a keyword parameter swallows that hash as keywords instead - every such
|
|
1507
|
+
# call site would raise ArgumentError.
|
|
1508
|
+
def self.version_ids(did_info, keep_location = false)
|
|
1509
|
+
normalize = lambda do |id|
|
|
1510
|
+
id = keep_location ? id.to_s : strip_location(id.to_s)
|
|
1511
|
+
id = percent_encode(id)
|
|
1512
|
+
id = "did:oyd:" + id if !id.start_with?("did:oyd:")
|
|
1513
|
+
id
|
|
1514
|
+
end
|
|
1515
|
+
canonical = normalize.call(did_info["did"])
|
|
1456
1516
|
own = document_id(did_info)
|
|
1457
1517
|
equivalentIds = []
|
|
1458
1518
|
did_info["log"].each do |log|
|
|
1459
1519
|
if log["op"] == 2 || log["op"] == 3
|
|
1460
|
-
eid =
|
|
1461
|
-
if eid != own
|
|
1520
|
+
eid = normalize.call(log["doc"])
|
|
1521
|
+
if eid != own && !equivalentIds.include?(eid)
|
|
1462
1522
|
equivalentIds << eid
|
|
1463
1523
|
end
|
|
1464
1524
|
end
|
|
@@ -1466,6 +1526,52 @@ class Oydid
|
|
|
1466
1526
|
[canonical, equivalentIds]
|
|
1467
1527
|
end
|
|
1468
1528
|
|
|
1529
|
+
# created / updated / versionId of the resolved document version, as DID Core
|
|
1530
|
+
# 7.1.3 defines them. Returned as a hash with string keys, ready to be merged
|
|
1531
|
+
# into didDocumentMetadata; a property the log cannot answer is absent rather
|
|
1532
|
+
# than null - in particular `updated`, which the spec requires to be "omitted
|
|
1533
|
+
# if an Update operation has never been performed on the DID document".
|
|
1534
|
+
#
|
|
1535
|
+
# The resolved version is did_info["did"] - dag_update walks the log to the
|
|
1536
|
+
# newest document and leaves its identifier there. Deriving both versionId and
|
|
1537
|
+
# the `updated` entry from it avoids guessing which log entry is the newest,
|
|
1538
|
+
# which timestamps alone cannot decide (they are client-supplied and two
|
|
1539
|
+
# entries can share a second).
|
|
1540
|
+
#
|
|
1541
|
+
# versionId is the bare document hash, without the "did:oyd:" prefix and
|
|
1542
|
+
# without a location: it is the method-specific identifier of that version, so
|
|
1543
|
+
# "did:oyd:" + versionId is the versioned DID.
|
|
1544
|
+
def self.version_metadata(did_info)
|
|
1545
|
+
as_datetime = lambda do |ts|
|
|
1546
|
+
# XML Datetime normalised to UTC, no sub-second precision (7.1.3)
|
|
1547
|
+
Time.at(ts.to_i).utc.strftime("%Y-%m-%dT%H:%M:%SZ") rescue nil
|
|
1548
|
+
end
|
|
1549
|
+
version_of = lambda do |id|
|
|
1550
|
+
strip_location(id.to_s).delete_prefix("did:oyd:")
|
|
1551
|
+
end
|
|
1552
|
+
|
|
1553
|
+
meta = {}
|
|
1554
|
+
resolved = version_of.call(did_info["did"])
|
|
1555
|
+
meta["versionId"] = resolved if resolved != ""
|
|
1556
|
+
return meta if did_info["log"].nil?
|
|
1557
|
+
|
|
1558
|
+
created_entry = did_info["log"].find { |el| el["op"].to_i == 2 }
|
|
1559
|
+
if !created_entry.nil? && !created_entry["ts"].nil?
|
|
1560
|
+
created = as_datetime.call(created_entry["ts"])
|
|
1561
|
+
meta["created"] = created if !created.nil?
|
|
1562
|
+
end
|
|
1563
|
+
|
|
1564
|
+
updated_entry = did_info["log"].find do |el|
|
|
1565
|
+
el["op"].to_i == 3 && version_of.call(el["doc"]) == resolved
|
|
1566
|
+
end
|
|
1567
|
+
if !updated_entry.nil? && !updated_entry["ts"].nil?
|
|
1568
|
+
updated = as_datetime.call(updated_entry["ts"])
|
|
1569
|
+
meta["updated"] = updated if !updated.nil?
|
|
1570
|
+
end
|
|
1571
|
+
|
|
1572
|
+
meta
|
|
1573
|
+
end
|
|
1574
|
+
|
|
1469
1575
|
# The identifier a resolved DID document carries as `id`.
|
|
1470
1576
|
#
|
|
1471
1577
|
# A did:oyd is the hash over its own document, so an update mints a new one
|
|
@@ -1632,8 +1738,12 @@ class Oydid
|
|
|
1632
1738
|
# is reciprocated - which it cannot be here, because all versions resolve
|
|
1633
1739
|
# to the same document. It stays for backwards compatibility; the
|
|
1634
1740
|
# authoritative statement is didDocumentMetadata canonicalId/equivalentId,
|
|
1635
|
-
# built from the same list
|
|
1636
|
-
|
|
1741
|
+
# built from the same list - except that this one keeps the location
|
|
1742
|
+
# suffix. alsoKnownAs therefore carries two kinds of statement: other
|
|
1743
|
+
# versions of the DID, and the location-bound variant of one. It must not
|
|
1744
|
+
# be read as a list of locations; the method specification says so
|
|
1745
|
+
# explicitly.
|
|
1746
|
+
equivalentIds = version_ids(did_info, true).last
|
|
1637
1747
|
if equivalentIds.length > 0
|
|
1638
1748
|
wd["alsoKnownAs"] = equivalentIds
|
|
1639
1749
|
end
|
data/spec/oydid_spec.rb
CHANGED
|
@@ -626,12 +626,80 @@ describe "OYDID handling" do
|
|
|
626
626
|
expect(canonical).to eq "did:oyd:" + second_did
|
|
627
627
|
end
|
|
628
628
|
|
|
629
|
-
|
|
629
|
+
# The identifier a relying party was handed is the one it asked for, which
|
|
630
|
+
# after an update is an earlier version than the document being served. Every
|
|
631
|
+
# other version is then equivalent to it - not just the current one. This is
|
|
632
|
+
# the case the HTTP request specs could not reach before update_did existed.
|
|
633
|
+
it "lists every other version when an earlier one was requested" do
|
|
634
|
+
canonical, equivalent = Oydid.version_ids(
|
|
635
|
+
"did" => "did:oyd:zC",
|
|
636
|
+
"did_requested" => "did:oyd:zA",
|
|
637
|
+
"log" => [{ "op" => 2, "doc" => "zA" },
|
|
638
|
+
{ "op" => 3, "doc" => "zB" },
|
|
639
|
+
{ "op" => 3, "doc" => "zC" }])
|
|
640
|
+
expect(canonical).to eq "did:oyd:zC"
|
|
641
|
+
expect(equivalent).to eq ["did:oyd:zB", "did:oyd:zC"]
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
it "lists the versions on both sides when a middle one was requested" do
|
|
645
|
+
canonical, equivalent = Oydid.version_ids(
|
|
646
|
+
"did" => "did:oyd:zC",
|
|
647
|
+
"did_requested" => "did:oyd:zB",
|
|
648
|
+
"log" => [{ "op" => 2, "doc" => "zA" },
|
|
649
|
+
{ "op" => 3, "doc" => "zB" },
|
|
650
|
+
{ "op" => 3, "doc" => "zC" }])
|
|
651
|
+
expect(canonical).to eq "did:oyd:zC"
|
|
652
|
+
expect(equivalent).to eq ["did:oyd:zA", "did:oyd:zC"]
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# canonicalId and equivalentId state identity, and "@<location>" states where
|
|
656
|
+
# a document is hosted. The same document can be mirrored at any number of
|
|
657
|
+
# locations, so the set of location-bound variants is open and equivalentId
|
|
658
|
+
# could not state it correctly - both are therefore location-free.
|
|
659
|
+
it "strips the location suffix from canonicalId and equivalentId" do
|
|
630
660
|
canonical, equivalent = Oydid.version_ids(
|
|
631
661
|
"did" => "did:oyd:" + second_did + "@https://example.org",
|
|
662
|
+
"did_requested" => "did:oyd:" + second_did,
|
|
632
663
|
"log" => [{ "op" => 2, "doc" => first_did + "@https://example.org" },
|
|
633
664
|
{ "op" => 3, "doc" => second_did + "@https://example.org" }])
|
|
634
|
-
expect(canonical).to eq "did:oyd:" + second_did
|
|
665
|
+
expect(canonical).to eq "did:oyd:" + second_did
|
|
666
|
+
expect(equivalent).to eq ["did:oyd:" + first_did]
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# A location-bound DID that was asked for stays the id of the document - DID
|
|
670
|
+
# Core: "the value of the id property in the retrieved DID document must
|
|
671
|
+
# always match the DID being resolved". Its location-free form is then a
|
|
672
|
+
# genuinely different string for the same subject, so it belongs in
|
|
673
|
+
# equivalentId alongside the earlier version.
|
|
674
|
+
it "lists the location-free form of the current version when a location-bound DID was requested" do
|
|
675
|
+
canonical, equivalent = Oydid.version_ids(
|
|
676
|
+
"did" => "did:oyd:" + second_did + "@https://example.org",
|
|
677
|
+
"did_requested" => "did:oyd:" + second_did + "@https://example.org",
|
|
678
|
+
"log" => [{ "op" => 2, "doc" => first_did + "@https://example.org" },
|
|
679
|
+
{ "op" => 3, "doc" => second_did + "@https://example.org" }])
|
|
680
|
+
expect(canonical).to eq "did:oyd:" + second_did
|
|
681
|
+
expect(equivalent).to eq ["did:oyd:" + first_did, "did:oyd:" + second_did]
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
# this used to list the DID as its own equivalent, in location-bound form,
|
|
685
|
+
# and hand out that same string as canonicalId
|
|
686
|
+
it "reports no equivalents for a never updated DID served from a location" do
|
|
687
|
+
canonical, equivalent = Oydid.version_ids(
|
|
688
|
+
"did" => "did:oyd:" + first_did + "@https://example.org",
|
|
689
|
+
"did_requested" => "did:oyd:" + first_did,
|
|
690
|
+
"log" => [{ "op" => 2, "doc" => first_did + "@https://example.org" },
|
|
691
|
+
{ "op" => 0, "doc" => "terminate" }])
|
|
692
|
+
expect(canonical).to eq "did:oyd:" + first_did
|
|
693
|
+
expect(equivalent).to eq []
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
it "keeps the location suffix for the alsoKnownAs list" do
|
|
697
|
+
canonical, equivalent = Oydid.version_ids(
|
|
698
|
+
{ "did" => "did:oyd:" + first_did + "@https://example.org",
|
|
699
|
+
"did_requested" => "did:oyd:" + first_did,
|
|
700
|
+
"log" => [{ "op" => 2, "doc" => first_did + "@https://example.org" }] },
|
|
701
|
+
true)
|
|
702
|
+
expect(canonical).to eq "did:oyd:" + first_did + "%40example.org"
|
|
635
703
|
expect(equivalent).to eq ["did:oyd:" + first_did + "%40example.org"]
|
|
636
704
|
end
|
|
637
705
|
|
|
@@ -661,13 +729,129 @@ describe "OYDID handling" do
|
|
|
661
729
|
}
|
|
662
730
|
wd = Oydid.w3c(Marshal.load(Marshal.dump(did_info)), {})
|
|
663
731
|
expect(wd["alsoKnownAs"]).to eq ["did:oyd:" + first_did]
|
|
664
|
-
expect(wd["alsoKnownAs"]).to eq Oydid.version_ids(did_info).last
|
|
732
|
+
expect(wd["alsoKnownAs"]).to eq Oydid.version_ids(did_info, true).last
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
# The two lists deliberately part ways on the location: identity statements
|
|
736
|
+
# drop it, alsoKnownAs keeps it - otherwise the location would disappear from
|
|
737
|
+
# the DID document altogether. Guards the live output for a never updated DID.
|
|
738
|
+
it "keeps the location-bound variant in alsoKnownAs while canonicalId drops it" do
|
|
739
|
+
stubbed_did = "zQmaBZTghndXTgxNwfbdpVLWdFf6faYE4oeuN2zzXdQt1kh"
|
|
740
|
+
did_info = {
|
|
741
|
+
"did" => "did:oyd:" + stubbed_did + "@https://example.org",
|
|
742
|
+
"did_requested" => "did:oyd:" + stubbed_did,
|
|
743
|
+
"doc" => { "doc" => { "hello" => "world" },
|
|
744
|
+
"key" => "z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S:" \
|
|
745
|
+
"z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK" },
|
|
746
|
+
"log" => [{ "op" => 2, "doc" => stubbed_did + "@https://example.org" }]
|
|
747
|
+
}
|
|
748
|
+
wd = Oydid.w3c(Marshal.load(Marshal.dump(did_info)), {})
|
|
749
|
+
expect(wd["id"]).to eq "did:oyd:" + stubbed_did
|
|
750
|
+
expect(wd["alsoKnownAs"]).to eq ["did:oyd:" + stubbed_did + "%40example.org"]
|
|
751
|
+
canonical, equivalent = Oydid.version_ids(did_info)
|
|
752
|
+
expect(canonical).to eq "did:oyd:" + stubbed_did
|
|
753
|
+
expect(equivalent).to eq []
|
|
754
|
+
end
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
# created / updated / versionId of the resolved version (DID Core 7.1.3).
|
|
758
|
+
# Without `updated` a consumer cannot tell how old the document in its hands is.
|
|
759
|
+
describe "version_metadata" do
|
|
760
|
+
let(:first_did) { "zQmSE1hzumtZ7AoK1qhHf4t5kiKsujMsJSHqoXtWrdd7K7W" }
|
|
761
|
+
let(:second_did) { "zQmfEb3KgYZjZUPLTHPmFPdcV6peF5itB5NmJ9N6gaxxE8K" }
|
|
762
|
+
|
|
763
|
+
# "The updated property is omitted if an Update operation has never been
|
|
764
|
+
# performed on the DID document." - 7.1.3
|
|
765
|
+
it "reports created and versionId, and omits updated, for a new DID" do
|
|
766
|
+
meta = Oydid.version_metadata(
|
|
767
|
+
"did" => "did:oyd:" + first_did + "@https://example.org",
|
|
768
|
+
"log" => [{ "op" => 2, "ts" => 1641224940, "doc" => first_did + "@https://example.org" },
|
|
769
|
+
{ "op" => 0, "ts" => 1641224940, "doc" => "terminate" }])
|
|
770
|
+
expect(meta["created"]).to eq "2022-01-03T15:49:00Z"
|
|
771
|
+
expect(meta["versionId"]).to eq first_did
|
|
772
|
+
expect(meta).not_to have_key("updated")
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
it "reports the timestamp of the update that produced the resolved version" do
|
|
776
|
+
meta = Oydid.version_metadata(
|
|
777
|
+
"did" => "did:oyd:" + second_did,
|
|
778
|
+
"log" => [{ "op" => 2, "ts" => 1641224940, "doc" => first_did },
|
|
779
|
+
{ "op" => 3, "ts" => 1641225032, "doc" => second_did },
|
|
780
|
+
{ "op" => 0, "ts" => 1641225032, "doc" => "terminate" }])
|
|
781
|
+
expect(meta["created"]).to eq "2022-01-03T15:49:00Z"
|
|
782
|
+
expect(meta["updated"]).to eq "2022-01-03T15:50:32Z"
|
|
783
|
+
expect(meta["versionId"]).to eq second_did
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
# the entry is picked by the version it produced, not by its timestamp:
|
|
787
|
+
# timestamps come from the client and two entries can share a second
|
|
788
|
+
it "ignores updates that did not produce the resolved version" do
|
|
789
|
+
meta = Oydid.version_metadata(
|
|
790
|
+
"did" => "did:oyd:zB",
|
|
791
|
+
"log" => [{ "op" => 2, "ts" => 1, "doc" => "zA" },
|
|
792
|
+
{ "op" => 3, "ts" => 2, "doc" => "zB" },
|
|
793
|
+
{ "op" => 3, "ts" => 3, "doc" => "zC" }])
|
|
794
|
+
expect(meta["versionId"]).to eq "zB"
|
|
795
|
+
expect(meta["updated"]).to eq "1970-01-01T00:00:02Z"
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
it "leaves out what the log cannot answer" do
|
|
799
|
+
expect(Oydid.version_metadata("did" => "did:oyd:" + first_did))
|
|
800
|
+
.to eq({ "versionId" => first_did })
|
|
801
|
+
expect(Oydid.version_metadata("did" => "did:oyd:" + first_did,
|
|
802
|
+
"log" => [{ "op" => 2, "doc" => first_did }]))
|
|
803
|
+
.to eq({ "versionId" => first_did })
|
|
665
804
|
end
|
|
666
805
|
end
|
|
667
806
|
|
|
668
807
|
# Which identifier a resolved DID document announces as its own. An update
|
|
669
808
|
# mints a new did:oyd, but the identifier a relying party holds is the one it
|
|
670
809
|
# asked for - that is the one the document has to carry.
|
|
810
|
+
# The "key" field is positional: "<public document key>:<public revocation key>".
|
|
811
|
+
# Reading it with .first/.last works only as long as it has exactly two slots -
|
|
812
|
+
# the day a third one is added (e.g. a key agreement key), .last silently
|
|
813
|
+
# returns the wrong key and the error only surfaces at revocation time.
|
|
814
|
+
# Every read must therefore use an explicit index.
|
|
815
|
+
describe "positional access to the key field" do
|
|
816
|
+
repo_root = File.expand_path("../../..", __FILE__)
|
|
817
|
+
sources = %w[
|
|
818
|
+
ruby-gem/lib
|
|
819
|
+
cli
|
|
820
|
+
repository/app
|
|
821
|
+
uni-registrar-driver-did-oyd/app
|
|
822
|
+
].map { |d| File.join(repo_root, d) }.select { |d| File.directory?(d) }
|
|
823
|
+
|
|
824
|
+
ruby_files = sources.flat_map { |d| Dir.glob(File.join(d, "**", "*.rb")) }
|
|
825
|
+
|
|
826
|
+
it "finds sources to scan" do
|
|
827
|
+
skip "running outside the repository checkout" if ruby_files.empty?
|
|
828
|
+
expect(ruby_files).not_to be_empty
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
it "never reads a split key field with .last" do
|
|
832
|
+
skip "running outside the repository checkout" if ruby_files.empty?
|
|
833
|
+
offenders = ruby_files.flat_map do |file|
|
|
834
|
+
File.readlines(file).each_with_index.filter_map do |line, i|
|
|
835
|
+
"#{file.sub(repo_root + "/", "")}:#{i + 1}" if line =~ /split\((["'])\:\1\)\.last/
|
|
836
|
+
end
|
|
837
|
+
end
|
|
838
|
+
expect(offenders).to eq []
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
it "never reads a split key field with .first" do
|
|
842
|
+
skip "running outside the repository checkout" if ruby_files.empty?
|
|
843
|
+
offenders = ruby_files.flat_map do |file|
|
|
844
|
+
File.readlines(file).each_with_index.filter_map do |line, i|
|
|
845
|
+
next unless line =~ /split\((["'])\:\1\)\.first/
|
|
846
|
+
# the DID identifier itself is also colon-separated and legitimately
|
|
847
|
+
# read with .first - only the key field is at issue here
|
|
848
|
+
"#{file.sub(repo_root + "/", "")}:#{i + 1}" if line.include?('"key"') || line.include?("'key'") || line =~ /_key(s)?\.split/
|
|
849
|
+
end
|
|
850
|
+
end
|
|
851
|
+
expect(offenders).to eq []
|
|
852
|
+
end
|
|
853
|
+
end
|
|
854
|
+
|
|
671
855
|
describe "document_id" do
|
|
672
856
|
let(:first_did) { "zQmSE1hzumtZ7AoK1qhHf4t5kiKsujMsJSHqoXtWrdd7K7W" }
|
|
673
857
|
let(:second_did) { "zQmfEb3KgYZjZUPLTHPmFPdcV6peF5itB5NmJ9N6gaxxE8K" }
|
|
@@ -719,4 +903,103 @@ describe "OYDID handling" do
|
|
|
719
903
|
end
|
|
720
904
|
end
|
|
721
905
|
|
|
906
|
+
|
|
907
|
+
describe "CMSM signature verification" do
|
|
908
|
+
# An in-memory stand-in for the session store the repository provides, so
|
|
909
|
+
# these examples run without a database and without the network.
|
|
910
|
+
class MemoryCmsmStore
|
|
911
|
+
def initialize; @data = {}; end
|
|
912
|
+
def set(session, payload); @data[session] = payload; end
|
|
913
|
+
def get(session); @data[session]; end
|
|
914
|
+
def delete(session); @data.delete(session); end
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
let(:store) { MemoryCmsmStore.new }
|
|
918
|
+
let(:priv) { Oydid.generate_private_key("", "ed25519-priv", {}).first }
|
|
919
|
+
let(:pub) { Oydid.public_key(priv, {}).first }
|
|
920
|
+
|
|
921
|
+
def cmsm_options(extra = {})
|
|
922
|
+
{ cmsm: true, key_type: "ed25519", cmsm_store: store,
|
|
923
|
+
return_secrets: true, skip_publish: true }.merge(extra)
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
# phase 1: hand over the public key, receive session and value to sign
|
|
927
|
+
def start_flow
|
|
928
|
+
status, msg = Oydid.create({ "key" => pub }, cmsm_options)
|
|
929
|
+
expect(msg).to eq("cmsm")
|
|
930
|
+
status.transform_keys(&:to_s)
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
describe "cmsm_verify_signature" do
|
|
934
|
+
it "accepts a signature made with the named key" do
|
|
935
|
+
signature = Oydid.sign("hello", priv, {}).first
|
|
936
|
+
expect(Oydid.cmsm_verify_signature("hello", signature, pub, "key-doc")).to be_nil
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
it "rejects a signature over a different value" do
|
|
940
|
+
signature = Oydid.sign("hello", priv, {}).first
|
|
941
|
+
msg = Oydid.cmsm_verify_signature("goodbye", signature, pub, "key-doc")
|
|
942
|
+
expect(msg).to eq("CMSM signature for key-doc is invalid")
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
it "rejects a signature made with a different key" do
|
|
946
|
+
other = Oydid.generate_private_key("", "ed25519-priv", {}).first
|
|
947
|
+
signature = Oydid.sign("hello", other, {}).first
|
|
948
|
+
expect(Oydid.cmsm_verify_signature("hello", signature, pub, "key-doc")).not_to be_nil
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
it "rejects nonsense instead of raising" do
|
|
952
|
+
expect(Oydid.cmsm_verify_signature("hello", "not-a-signature", pub, "key-doc")).not_to be_nil
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
it "refuses to verify without a public key" do
|
|
956
|
+
signature = Oydid.sign("hello", priv, {}).first
|
|
957
|
+
msg = Oydid.cmsm_verify_signature("hello", signature, "", "key-doc")
|
|
958
|
+
expect(msg).to include("no public key in session")
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
# the message is what the REST layers match on to answer 400 instead of 500
|
|
962
|
+
it "phrases failures as client errors" do
|
|
963
|
+
msg = Oydid.cmsm_verify_signature("hello", "zBogus", pub, "key-rev")
|
|
964
|
+
expect(msg).to start_with("CMSM ")
|
|
965
|
+
end
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
describe "a running flow" do
|
|
969
|
+
it "advances when the signature is correct" do
|
|
970
|
+
phase1 = start_flow
|
|
971
|
+
signature = Oydid.sign(phase1["sign"], priv, {}).first
|
|
972
|
+
|
|
973
|
+
status, msg = Oydid.create({}, cmsm_options(cmsm_session: phase1["session"], sig: signature))
|
|
974
|
+
|
|
975
|
+
expect(msg).to eq("cmsm")
|
|
976
|
+
expect(status.transform_keys(&:to_s)["session"]).to eq(phase1["session"])
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
# Before this check the flow took any signature, built the log entries from
|
|
980
|
+
# it and answered 200. The DID did not resolve, but it had claimed the
|
|
981
|
+
# public key - so anyone could burn a key they did not hold.
|
|
982
|
+
it "refuses a signature the client cannot have made" do
|
|
983
|
+
phase1 = start_flow
|
|
984
|
+
forged = Oydid.sign(phase1["sign"],
|
|
985
|
+
Oydid.generate_private_key("", "ed25519-priv", {}).first, {}).first
|
|
986
|
+
|
|
987
|
+
status, msg = Oydid.create({}, cmsm_options(cmsm_session: phase1["session"], sig: forged))
|
|
988
|
+
|
|
989
|
+
expect(status).to be_nil
|
|
990
|
+
expect(msg).to eq("CMSM signature for key-doc is invalid")
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
it "refuses a signature over a value from another flow" do
|
|
994
|
+
phase1 = start_flow
|
|
995
|
+
other = start_flow
|
|
996
|
+
signature = Oydid.sign(other["sign"], priv, {}).first
|
|
997
|
+
|
|
998
|
+
status, msg = Oydid.create({}, cmsm_options(cmsm_session: phase1["session"], sig: signature))
|
|
999
|
+
|
|
1000
|
+
expect(status).to be_nil
|
|
1001
|
+
expect(msg).to start_with("CMSM ")
|
|
1002
|
+
end
|
|
1003
|
+
end
|
|
1004
|
+
end
|
|
722
1005
|
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.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Christoph Fabianek
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-08-
|
|
10
|
+
date: 2026-08-28 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: simple_dag
|