oydid 0.8.2 → 0.9.1

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: 66decf23cb4e474d0703a8948e20474be619c05083bd8abd5ab8d45857ac4ac0
4
- data.tar.gz: db83f3c7ba0a20c5e4f8fd8ba8b9977b42b9b78fc56ad11df8a8bf12e9657704
3
+ metadata.gz: 9c74b5cd9e592efe1514c9110cff25a131ddc0a45727e035d48656bc0c20d2f8
4
+ data.tar.gz: bd936eb03e018e15ae334907c7087629f48f17c2cc54bdeebb8a81542e2f2af9
5
5
  SHA512:
6
- metadata.gz: 8856635688f0dd47b246443d7fcc7a994ceef87e7d2594530da0f3489445a544f3c7cc9d20327fec7da3988e83872d1ddf3c854bd4db25b367b72fa917ba09c3
7
- data.tar.gz: '08956cf2da8ed63a35261a7ffa01cb58b11a177c77fe8b5f873f8ed4d59592695c0e80bd72d118c28f4266728b10e420b4eaab7189c406dd6c7731e52f40e38d'
6
+ metadata.gz: 8fddcdf2ce2328ba9329263b1de2826cec3c3d4947c7cd5e599972798cffb10887e8bf2205d208961494a7296a818c90a4fea679f2b9b1c01109645c8fde59bf
7
+ data.tar.gz: fdaab3ac52a14b4313db2d7d52058a41bdd6b72b2ca580441f5620a246d73d2fdf753a0530aef76d44520d8347573852aa6e3ae27d173371d54b09eeb86c874c
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.8.2
1
+ 0.9.1
data/lib/oydid/basic.rb CHANGED
@@ -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.rb CHANGED
@@ -68,8 +68,15 @@ class Oydid
68
68
  end
69
69
 
70
70
  # setup
71
+ #
72
+ # did_requested is the identifier the caller asked for. dag_update
73
+ # overwrites "did" with every version it walks through, so by the time a
74
+ # DID document is rendered the requested identifier would be lost - and
75
+ # after an update that is exactly the one a relying party holds, e.g.
76
+ # printed on a product's data carrier.
71
77
  currentDID = {
72
78
  "did": did,
79
+ "did_requested": did,
73
80
  "doc": "",
74
81
  "log": [],
75
82
  "doc_log_id": nil,
@@ -1426,6 +1433,130 @@ class Oydid
1426
1433
  wd
1427
1434
  end
1428
1435
 
1436
+ # Identifiers of every published version of a DID, oldest first.
1437
+ #
1438
+ # A did:oyd is the hash over its own DID document, so every update mints a
1439
+ # new identifier while the earlier ones stay resolvable through the log.
1440
+ # The version reached by traversing the log is the canonical identifier,
1441
+ # every other version identifier is logically equivalent to it - which is
1442
+ # what DID Core expresses as didDocumentMetadata canonicalId/equivalentId.
1443
+ #
1444
+ # This lives in the gem rather than in the resolving controllers because
1445
+ # there are three of them (repository resolve and resolve_full, plus the
1446
+ # uniresolver plugin) and they had drifted into computing this list with
1447
+ # different rules.
1448
+ #
1449
+ # Both values are location-free: the "@<location>" suffix says where a
1450
+ # document is hosted, not who it is, and the same document can be mirrored at
1451
+ # any number of locations - so the set of location-bound variants is open and
1452
+ # equivalentId could not state it correctly anyway. The raw values these are
1453
+ # built from do carry a location (dag_update sets did_info["did"] from the log
1454
+ # entry, and log[]["doc"] is written with one), which is why strip_location is
1455
+ # applied here rather than assumed. The location-bound variant stays in
1456
+ # alsoKnownAs; document_id is deliberately left alone (see strip_location).
1457
+ #
1458
+ # Returns [canonicalId, equivalentIds]; equivalentIds never contains the
1459
+ # identifier the DID document itself carries as `id` (see document_id) and is
1460
+ # empty - not a set with the DID itself in it - while there is only one
1461
+ # version.
1462
+ # keep_location = true reproduces the pre-0.9.1 values, location suffix and
1463
+ # all. Only w3c uses it, to keep alsoKnownAs listing the location-bound
1464
+ # variant of a DID - dropping that would take the location out of the
1465
+ # document altogether, and alsoKnownAs is where it belongs.
1466
+ #
1467
+ # Deliberately a positional argument: callers pass did_info as a braceless
1468
+ # hash literal (version_ids("did" => ..., "log" => ...)), and a method with
1469
+ # a keyword parameter swallows that hash as keywords instead - every such
1470
+ # call site would raise ArgumentError.
1471
+ def self.version_ids(did_info, keep_location = false)
1472
+ normalize = lambda do |id|
1473
+ id = keep_location ? id.to_s : strip_location(id.to_s)
1474
+ id = percent_encode(id)
1475
+ id = "did:oyd:" + id if !id.start_with?("did:oyd:")
1476
+ id
1477
+ end
1478
+ canonical = normalize.call(did_info["did"])
1479
+ own = document_id(did_info)
1480
+ equivalentIds = []
1481
+ did_info["log"].each do |log|
1482
+ if log["op"] == 2 || log["op"] == 3
1483
+ eid = normalize.call(log["doc"])
1484
+ if eid != own && !equivalentIds.include?(eid)
1485
+ equivalentIds << eid
1486
+ end
1487
+ end
1488
+ end unless did_info["log"].nil?
1489
+ [canonical, equivalentIds]
1490
+ end
1491
+
1492
+ # created / updated / versionId of the resolved document version, as DID Core
1493
+ # 7.1.3 defines them. Returned as a hash with string keys, ready to be merged
1494
+ # into didDocumentMetadata; a property the log cannot answer is absent rather
1495
+ # than null - in particular `updated`, which the spec requires to be "omitted
1496
+ # if an Update operation has never been performed on the DID document".
1497
+ #
1498
+ # The resolved version is did_info["did"] - dag_update walks the log to the
1499
+ # newest document and leaves its identifier there. Deriving both versionId and
1500
+ # the `updated` entry from it avoids guessing which log entry is the newest,
1501
+ # which timestamps alone cannot decide (they are client-supplied and two
1502
+ # entries can share a second).
1503
+ #
1504
+ # versionId is the bare document hash, without the "did:oyd:" prefix and
1505
+ # without a location: it is the method-specific identifier of that version, so
1506
+ # "did:oyd:" + versionId is the versioned DID.
1507
+ def self.version_metadata(did_info)
1508
+ as_datetime = lambda do |ts|
1509
+ # XML Datetime normalised to UTC, no sub-second precision (7.1.3)
1510
+ Time.at(ts.to_i).utc.strftime("%Y-%m-%dT%H:%M:%SZ") rescue nil
1511
+ end
1512
+ version_of = lambda do |id|
1513
+ strip_location(id.to_s).delete_prefix("did:oyd:")
1514
+ end
1515
+
1516
+ meta = {}
1517
+ resolved = version_of.call(did_info["did"])
1518
+ meta["versionId"] = resolved if resolved != ""
1519
+ return meta if did_info["log"].nil?
1520
+
1521
+ created_entry = did_info["log"].find { |el| el["op"].to_i == 2 }
1522
+ if !created_entry.nil? && !created_entry["ts"].nil?
1523
+ created = as_datetime.call(created_entry["ts"])
1524
+ meta["created"] = created if !created.nil?
1525
+ end
1526
+
1527
+ updated_entry = did_info["log"].find do |el|
1528
+ el["op"].to_i == 3 && version_of.call(el["doc"]) == resolved
1529
+ end
1530
+ if !updated_entry.nil? && !updated_entry["ts"].nil?
1531
+ updated = as_datetime.call(updated_entry["ts"])
1532
+ meta["updated"] = updated if !updated.nil?
1533
+ end
1534
+
1535
+ meta
1536
+ end
1537
+
1538
+ # The identifier a resolved DID document carries as `id`.
1539
+ #
1540
+ # A did:oyd is the hash over its own document, so an update mints a new one
1541
+ # and the resolution walks the log to the newest version. Answering with that
1542
+ # newest identifier means a relying party never sees the DID it asked for -
1543
+ # the one on the data carrier, in the credential, in the database. So the
1544
+ # requested identifier is what goes into the document; which version it is
1545
+ # remains readable from didDocumentMetadata canonicalId.
1546
+ #
1547
+ # Callers that build a did_info by hand - the registrar endpoints do, for a
1548
+ # DID that was just created or updated - carry no did_requested and keep
1549
+ # getting the identifier they passed in.
1550
+ def self.document_id(did_info)
1551
+ did = did_info["did_requested"].to_s
1552
+ did = did_info["did"].to_s if did == ""
1553
+ did = percent_encode(did)
1554
+ if !did.start_with?("did:oyd:")
1555
+ did = "did:oyd:" + did
1556
+ end
1557
+ did
1558
+ end
1559
+
1429
1560
  def self.w3c(did_info, options)
1430
1561
  # check if doc is already W3C DID
1431
1562
  is_already_w3c_did = (did_info.transform_keys(&:to_s)["doc"]["doc"].has_key?("@context") &&
@@ -1434,10 +1565,7 @@ class Oydid
1434
1565
  if is_already_w3c_did
1435
1566
  return did_info.transform_keys(&:to_s)["doc"]["doc"]
1436
1567
  end
1437
- did = percent_encode(did_info["did"])
1438
- if !did.start_with?("did:oyd:")
1439
- did = "did:oyd:" + did
1440
- end
1568
+ did = document_id(did_info)
1441
1569
 
1442
1570
  didDoc = did_info.dup.transform_keys(&:to_s)["doc"]
1443
1571
  pubDocKey = didDoc["key"].split(":")[0] rescue ""
@@ -1568,15 +1696,17 @@ class Oydid
1568
1696
  end
1569
1697
  end
1570
1698
 
1571
- equivalentIds = []
1572
- did_info["log"].each do |log|
1573
- if log["op"] == 2 || log["op"] == 3
1574
- eid = percent_encode("did:oyd:" + log["doc"])
1575
- if eid != did
1576
- equivalentIds << eid
1577
- end
1578
- end
1579
- end unless did_info["log"].nil?
1699
+ # alsoKnownAs is a statement about the DID *subject*, and DID Core calls
1700
+ # it best practice not to read it as equivalence unless the relationship
1701
+ # is reciprocated - which it cannot be here, because all versions resolve
1702
+ # to the same document. It stays for backwards compatibility; the
1703
+ # authoritative statement is didDocumentMetadata canonicalId/equivalentId,
1704
+ # built from the same list - except that this one keeps the location
1705
+ # suffix. alsoKnownAs therefore carries two kinds of statement: other
1706
+ # versions of the DID, and the location-bound variant of one. It must not
1707
+ # be read as a list of locations; the method specification says so
1708
+ # explicitly.
1709
+ equivalentIds = version_ids(did_info, true).last
1580
1710
  if equivalentIds.length > 0
1581
1711
  wd["alsoKnownAs"] = equivalentIds
1582
1712
  end
@@ -1 +1 @@
1
- [{"did":"zQmaBZTghndXTgxNwfbdpVLWdFf6faYE4oeuN2zzXdQt1kh","doc":{"doc":{"simple":"example"},"key":"z6MusYB5iT5krCHYsZ76EzBaTdRwGKsaBhMcSbrXaPJgkuRQ:z6Mv7EYihbAat6Wq7GsjNsjcxt58dZT8fmsRjQGTkYamYrjB","log":"zQmVwMvovLy5KNYHHVHQ1wv8J7y9L6UPE8eyU4tzypFWtYe"},"log":[{"ts":1641224736,"op":2,"doc":"zQmaBZTghndXTgxNwfbdpVLWdFf6faYE4oeuN2zzXdQt1kh","sig":"z3Kb5qeReCqr3ftxpf2i5UypUwrzrVkyspMtaDcb6e9YdHVSptcAFgvwbgk3qWqspTcGiKDYKXZZh8g6XyM2WPmNp","previous":[]},{"ts":1641224736,"op":0,"doc":"zQmT8SG7a238bF7wdV7LdrEAQpimqhKGor7CQsjtCYdZdTS","sig":"z63hu8LseptBrvB2kEDwhPP35sBj7JDDJsEDW85cjRkrjjac9ZV3HxPW9NVKewHcQYwrVLVsnDCcm1RjbEARE5rJU","previous":[]}],"doc_log_id":0,"termination_log_id":1,"error":0,"message":"","verification":"identifier: zQmaBZTghndXTgxNwfbdpVLWdFf6faYE4oeuN2zzXdQt1kh\n✅ is hash of DID Document:\n{\n \"doc\": {\n \"simple\": \"example\"\n },\n \"key\": \"z6MusYB5iT5krCHYsZ76EzBaTdRwGKsaBhMcSbrXaPJgkuRQ:z6Mv7EYihbAat6Wq7GsjNsjcxt58dZT8fmsRjQGTkYamYrjB\",\n \"log\": \"zQmVwMvovLy5KNYHHVHQ1wv8J7y9L6UPE8eyU4tzypFWtYe\"\n}\n(Details: https://ownyourdata.github.io/oydid/#calculate_hash)\n\n'log' reference in DID Document: zQmVwMvovLy5KNYHHVHQ1wv8J7y9L6UPE8eyU4tzypFWtYe\n✅ is hash of TERMINATE log record:\n{\n \"ts\": 1641224736,\n \"op\": 0,\n \"doc\": \"zQmT8SG7a238bF7wdV7LdrEAQpimqhKGor7CQsjtCYdZdTS\",\n \"sig\": \"z63hu8LseptBrvB2kEDwhPP35sBj7JDDJsEDW85cjRkrjjac9ZV3HxPW9NVKewHcQYwrVLVsnDCcm1RjbEARE5rJU\",\n \"previous\": []\n}\n(Details: https://ownyourdata.github.io/oydid/#calculate_hash)\n\nRevocation reference in log record: zQmT8SG7a238bF7wdV7LdrEAQpimqhKGor7CQsjtCYdZdTS\n✅ cannot find revocation record searching at\n- https://oydid.ownyourdata.eu\n(Details: https://ownyourdata.github.io/oydid/#retrieve_log)\n\n","full_log":[{"ts":1641224736,"op":2,"doc":"zQmaBZTghndXTgxNwfbdpVLWdFf6faYE4oeuN2zzXdQt1kh","sig":"z3Kb5qeReCqr3ftxpf2i5UypUwrzrVkyspMtaDcb6e9YdHVSptcAFgvwbgk3qWqspTcGiKDYKXZZh8g6XyM2WPmNp","previous":[]},{"ts":1641224736,"op":0,"doc":"zQmT8SG7a238bF7wdV7LdrEAQpimqhKGor7CQsjtCYdZdTS","sig":"z63hu8LseptBrvB2kEDwhPP35sBj7JDDJsEDW85cjRkrjjac9ZV3HxPW9NVKewHcQYwrVLVsnDCcm1RjbEARE5rJU","previous":[]}]},""]
1
+ [{"did":"zQmaBZTghndXTgxNwfbdpVLWdFf6faYE4oeuN2zzXdQt1kh","did_requested":"did:oyd:zQmaBZTghndXTgxNwfbdpVLWdFf6faYE4oeuN2zzXdQt1kh","doc":{"doc":{"simple":"example"},"key":"z6MusYB5iT5krCHYsZ76EzBaTdRwGKsaBhMcSbrXaPJgkuRQ:z6Mv7EYihbAat6Wq7GsjNsjcxt58dZT8fmsRjQGTkYamYrjB","log":"zQmVwMvovLy5KNYHHVHQ1wv8J7y9L6UPE8eyU4tzypFWtYe"},"log":[{"ts":1641224736,"op":2,"doc":"zQmaBZTghndXTgxNwfbdpVLWdFf6faYE4oeuN2zzXdQt1kh","sig":"z3Kb5qeReCqr3ftxpf2i5UypUwrzrVkyspMtaDcb6e9YdHVSptcAFgvwbgk3qWqspTcGiKDYKXZZh8g6XyM2WPmNp","previous":[]},{"ts":1641224736,"op":0,"doc":"zQmT8SG7a238bF7wdV7LdrEAQpimqhKGor7CQsjtCYdZdTS","sig":"z63hu8LseptBrvB2kEDwhPP35sBj7JDDJsEDW85cjRkrjjac9ZV3HxPW9NVKewHcQYwrVLVsnDCcm1RjbEARE5rJU","previous":[]}],"doc_log_id":0,"termination_log_id":1,"error":0,"message":"","verification":"identifier: zQmaBZTghndXTgxNwfbdpVLWdFf6faYE4oeuN2zzXdQt1kh\n✅ is hash of DID Document:\n{\n \"doc\": {\n \"simple\": \"example\"\n },\n \"key\": \"z6MusYB5iT5krCHYsZ76EzBaTdRwGKsaBhMcSbrXaPJgkuRQ:z6Mv7EYihbAat6Wq7GsjNsjcxt58dZT8fmsRjQGTkYamYrjB\",\n \"log\": \"zQmVwMvovLy5KNYHHVHQ1wv8J7y9L6UPE8eyU4tzypFWtYe\"\n}\n(Details: https://ownyourdata.github.io/oydid/#calculate_hash)\n\n'log' reference in DID Document: zQmVwMvovLy5KNYHHVHQ1wv8J7y9L6UPE8eyU4tzypFWtYe\n✅ is hash of TERMINATE log record:\n{\n \"ts\": 1641224736,\n \"op\": 0,\n \"doc\": \"zQmT8SG7a238bF7wdV7LdrEAQpimqhKGor7CQsjtCYdZdTS\",\n \"sig\": \"z63hu8LseptBrvB2kEDwhPP35sBj7JDDJsEDW85cjRkrjjac9ZV3HxPW9NVKewHcQYwrVLVsnDCcm1RjbEARE5rJU\",\n \"previous\": []\n}\n(Details: https://ownyourdata.github.io/oydid/#calculate_hash)\n\nRevocation reference in log record: zQmT8SG7a238bF7wdV7LdrEAQpimqhKGor7CQsjtCYdZdTS\n✅ cannot find revocation record searching at\n- https://oydid.ownyourdata.eu\n(Details: https://ownyourdata.github.io/oydid/#retrieve_log)\n\n","full_log":[{"ts":1641224736,"op":2,"doc":"zQmaBZTghndXTgxNwfbdpVLWdFf6faYE4oeuN2zzXdQt1kh","sig":"z3Kb5qeReCqr3ftxpf2i5UypUwrzrVkyspMtaDcb6e9YdHVSptcAFgvwbgk3qWqspTcGiKDYKXZZh8g6XyM2WPmNp","previous":[]},{"ts":1641224736,"op":0,"doc":"zQmT8SG7a238bF7wdV7LdrEAQpimqhKGor7CQsjtCYdZdTS","sig":"z63hu8LseptBrvB2kEDwhPP35sBj7JDDJsEDW85cjRkrjjac9ZV3HxPW9NVKewHcQYwrVLVsnDCcm1RjbEARE5rJU","previous":[]}]},""]
data/spec/oydid_spec.rb CHANGED
@@ -584,4 +584,278 @@ describe "OYDID handling" do
584
584
  end
585
585
  end
586
586
 
587
+ # Identifiers of the published versions of a DID. Every update mints a new
588
+ # one, so a resolver has to be able to say which is current and which are
589
+ # earlier - didDocumentMetadata canonicalId and equivalentId.
590
+ describe "version_ids" do
591
+ let(:first_did) { "zQmSE1hzumtZ7AoK1qhHf4t5kiKsujMsJSHqoXtWrdd7K7W" }
592
+ let(:second_did) { "zQmfEb3KgYZjZUPLTHPmFPdcV6peF5itB5NmJ9N6gaxxE8K" }
593
+
594
+ it "reports a freshly created DID as canonical without equivalents" do
595
+ canonical, equivalent = Oydid.version_ids(
596
+ "did" => "did:oyd:" + first_did,
597
+ "log" => [{ "op" => 2, "doc" => first_did },
598
+ { "op" => 0, "doc" => "terminate" }])
599
+ expect(canonical).to eq "did:oyd:" + first_did
600
+ expect(equivalent).to eq []
601
+ end
602
+
603
+ it "names the current version canonical and the previous one equivalent" do
604
+ canonical, equivalent = Oydid.version_ids(
605
+ "did" => "did:oyd:" + second_did,
606
+ "log" => [{ "op" => 1, "doc" => "revoke" },
607
+ { "op" => 2, "doc" => first_did },
608
+ { "op" => 3, "doc" => second_did },
609
+ { "op" => 0, "doc" => "terminate" }])
610
+ expect(canonical).to eq "did:oyd:" + second_did
611
+ expect(equivalent).to eq ["did:oyd:" + first_did]
612
+ end
613
+
614
+ it "keeps every earlier version, oldest first" do
615
+ canonical, equivalent = Oydid.version_ids(
616
+ "did" => "did:oyd:zC",
617
+ "log" => [{ "op" => 2, "doc" => "zA" },
618
+ { "op" => 3, "doc" => "zB" },
619
+ { "op" => 3, "doc" => "zC" }])
620
+ expect(canonical).to eq "did:oyd:zC"
621
+ expect(equivalent).to eq ["did:oyd:zA", "did:oyd:zB"]
622
+ end
623
+
624
+ it "adds the method prefix when the resolved DID carries none" do
625
+ canonical, = Oydid.version_ids("did" => second_did, "log" => [])
626
+ expect(canonical).to eq "did:oyd:" + second_did
627
+ end
628
+
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
660
+ canonical, equivalent = Oydid.version_ids(
661
+ "did" => "did:oyd:" + second_did + "@https://example.org",
662
+ "did_requested" => "did:oyd:" + second_did,
663
+ "log" => [{ "op" => 2, "doc" => first_did + "@https://example.org" },
664
+ { "op" => 3, "doc" => second_did + "@https://example.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"
703
+ expect(equivalent).to eq ["did:oyd:" + first_did + "%40example.org"]
704
+ end
705
+
706
+ it "tolerates a missing log" do
707
+ canonical, equivalent = Oydid.version_ids("did" => "did:oyd:" + second_did)
708
+ expect(canonical).to eq "did:oyd:" + second_did
709
+ expect(equivalent).to eq []
710
+ end
711
+
712
+ # w3c builds alsoKnownAs from the same list. The two used to be computed
713
+ # separately in three places and drifted apart; this guards the merge.
714
+ #
715
+ # The canonical DID has to be one this suite stubs: w3c looks up delegation
716
+ # keys for it, and WebMock::NetConnectNotAllowedError descends from Exception
717
+ # rather than StandardError, so the inline `rescue []` around
718
+ # getDelegatedPubKeysFromDID does not catch it. Only the canonical DID is
719
+ # fetched - log entries are read, not resolved.
720
+ it "agrees with the alsoKnownAs the DID document carries" do
721
+ stubbed_did = "zQmaBZTghndXTgxNwfbdpVLWdFf6faYE4oeuN2zzXdQt1kh"
722
+ did_info = {
723
+ "did" => "did:oyd:" + stubbed_did,
724
+ "doc" => { "doc" => { "hello" => "world" },
725
+ "key" => "z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S:" \
726
+ "z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK" },
727
+ "log" => [{ "op" => 2, "doc" => first_did },
728
+ { "op" => 3, "doc" => stubbed_did }]
729
+ }
730
+ wd = Oydid.w3c(Marshal.load(Marshal.dump(did_info)), {})
731
+ expect(wd["alsoKnownAs"]).to eq ["did:oyd:" + first_did]
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 })
804
+ end
805
+ end
806
+
807
+ # Which identifier a resolved DID document announces as its own. An update
808
+ # mints a new did:oyd, but the identifier a relying party holds is the one it
809
+ # asked for - that is the one the document has to carry.
810
+ describe "document_id" do
811
+ let(:first_did) { "zQmSE1hzumtZ7AoK1qhHf4t5kiKsujMsJSHqoXtWrdd7K7W" }
812
+ let(:second_did) { "zQmfEb3KgYZjZUPLTHPmFPdcV6peF5itB5NmJ9N6gaxxE8K" }
813
+ # a DID this suite stubs - w3c looks up delegation keys for whatever it
814
+ # emits as id, see the note on the alsoKnownAs example above
815
+ let(:stubbed_did) { "zQmaBZTghndXTgxNwfbdpVLWdFf6faYE4oeuN2zzXdQt1kh" }
816
+
817
+ it "answers with the requested DID when the resolver kept it" do
818
+ expect(Oydid.document_id("did" => "did:oyd:" + second_did,
819
+ "did_requested" => "did:oyd:" + first_did))
820
+ .to eq "did:oyd:" + first_did
821
+ end
822
+
823
+ it "falls back to the resolved DID for a did_info built by hand" do
824
+ expect(Oydid.document_id("did" => "did:oyd:" + second_did))
825
+ .to eq "did:oyd:" + second_did
826
+ end
827
+
828
+ it "adds the method prefix and percent-encodes a location suffix" do
829
+ expect(Oydid.document_id("did" => "", "did_requested" => first_did + "@https://example.org"))
830
+ .to eq "did:oyd:" + first_did + "%40example.org"
831
+ end
832
+
833
+ # the point of the exercise: a DID printed on a data carrier keeps naming
834
+ # itself after an update, while the current version stays readable
835
+ it "keeps the requested DID as id and moves the current one to alsoKnownAs" do
836
+ did_info = {
837
+ "did" => "did:oyd:" + second_did,
838
+ "did_requested" => "did:oyd:" + stubbed_did,
839
+ "doc" => { "doc" => { "hello" => "world" },
840
+ "key" => "z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S:" \
841
+ "z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK" },
842
+ "log" => [{ "op" => 2, "doc" => stubbed_did },
843
+ { "op" => 3, "doc" => second_did }]
844
+ }
845
+ wd = Oydid.w3c(Marshal.load(Marshal.dump(did_info)), {})
846
+ expect(wd["id"]).to eq "did:oyd:" + stubbed_did
847
+ expect(wd["alsoKnownAs"]).to eq ["did:oyd:" + second_did]
848
+ # the entries of verificationMethod are built as hash literals with
849
+ # "id": - Ruby makes that a symbol key, the surrounding keys are strings
850
+ expect(wd["verificationMethod"].first[:id]).to start_with("did:oyd:" + stubbed_did + "#")
851
+ expect(wd["verificationMethod"].first[:controller]).to eq "did:oyd:" + stubbed_did
852
+
853
+ # canonicalId still names the version the log resolves to, and the
854
+ # equivalent list is everything the document does not call itself
855
+ canonical, equivalent = Oydid.version_ids(did_info)
856
+ expect(canonical).to eq "did:oyd:" + second_did
857
+ expect(equivalent).to eq ["did:oyd:" + second_did]
858
+ end
859
+ end
860
+
587
861
  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.8.2
4
+ version: 0.9.1
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-26 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: simple_dag