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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d3e561caa744ec1b341317d142b27f5b9d7d9dddf92b2d18aaf2117fff05cb9a
4
- data.tar.gz: 6aa242687b1765148f79e9b6911d22e81339159f2f0c45445c0be1ce804fe0ad
3
+ metadata.gz: 072b6ccce2a1b7bf3c607aa01d3a3c97cfd37f8e41d52bb9db529d539ccb6eda
4
+ data.tar.gz: 75ccf1a0357d60e629dac7ea3a046cfe0933693a04b5179bf0db7b54929abc6b
5
5
  SHA512:
6
- metadata.gz: 35d735d7f16edf812645b91034b5c8824437144fb86b2dba19e2b165a1912279fc3e6b09916cde37004402bb309f5faad7248105b41de07f293892f7e6963d09
7
- data.tar.gz: 0e333ede2a2b46e9c6264264f638cf3b54823cab9663a11af3bf776f796965210abb39b3c37555d0824f67d9b55506d3065803b1a94182097acdf0b11b9c670e
6
+ metadata.gz: c936a18cf7106ae73e76fb30fc5ee789a50330a342dfa9624f29bd03574cde42b9c8a4b6a710e0f7e3ed9c6865fb8ec49aaac2cf7cd24c5d5330405e4f20cd81
7
+ data.tar.gz: 3d6c15c81d795cd470f75031722f211aac478dc239a1ece245a64e1b00144d7d37eadfe3c5db8dab4ebfd487e685011f50615f5c40d05ea502d59762cbb51a1f
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.0
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(":").first rescue nil
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(":").first] rescue nil
370
+ keys = [did_document["doc"]["key"].split(":")[0]] rescue nil
371
371
  when "rev"
372
- keys = [did_document["doc"]["key"].split(":").last] rescue nil
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(":").first rescue ""
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(":").first)
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(":").first.to_s
387
- old_publicRevKey = old_did_key.split(":").last.to_s
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(":").last.to_s
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
- def self.version_ids(did_info)
1452
- canonical = percent_encode(did_info["did"].to_s)
1453
- if !canonical.start_with?("did:oyd:")
1454
- canonical = "did:oyd:" + canonical
1455
- end
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 = percent_encode("did:oyd:" + log["doc"].to_s)
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
- equivalentIds = version_ids(did_info).last
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
- it "percent-encodes a location suffix" do
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 + "%40example.org"
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.0
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-23 00:00:00.000000000 Z
10
+ date: 2026-08-28 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: simple_dag