@interop/wallet-core 0.61.0 → 0.62.0

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.
Files changed (83) hide show
  1. package/dist/clientAnnex/forgetLast.d.ts +66 -19
  2. package/dist/clientAnnex/forgetLast.d.ts.map +1 -1
  3. package/dist/clientAnnex/forgetLast.js +120 -38
  4. package/dist/clientAnnex/forgetLast.js.map +1 -1
  5. package/dist/clientAnnex/heal.d.ts +64 -29
  6. package/dist/clientAnnex/heal.d.ts.map +1 -1
  7. package/dist/clientAnnex/heal.js +287 -50
  8. package/dist/clientAnnex/heal.js.map +1 -1
  9. package/dist/clientAnnex/index.d.ts +2 -2
  10. package/dist/clientAnnex/index.d.ts.map +1 -1
  11. package/dist/clientAnnex/index.js +2 -2
  12. package/dist/clientAnnex/index.js.map +1 -1
  13. package/dist/clientAnnex/ladder.d.ts +246 -18
  14. package/dist/clientAnnex/ladder.d.ts.map +1 -1
  15. package/dist/clientAnnex/ladder.js +742 -53
  16. package/dist/clientAnnex/ladder.js.map +1 -1
  17. package/dist/clientAnnex/ladderAnchored.d.ts +113 -6
  18. package/dist/clientAnnex/ladderAnchored.d.ts.map +1 -1
  19. package/dist/clientAnnex/ladderAnchored.js +208 -27
  20. package/dist/clientAnnex/ladderAnchored.js.map +1 -1
  21. package/dist/clientAnnex/log.d.ts +37 -1
  22. package/dist/clientAnnex/log.d.ts.map +1 -1
  23. package/dist/clientAnnex/log.js +34 -10
  24. package/dist/clientAnnex/log.js.map +1 -1
  25. package/dist/clientAnnex/recoveryLadderAnchored.d.ts +32 -7
  26. package/dist/clientAnnex/recoveryLadderAnchored.d.ts.map +1 -1
  27. package/dist/clientAnnex/recoveryLadderAnchored.js +124 -23
  28. package/dist/clientAnnex/recoveryLadderAnchored.js.map +1 -1
  29. package/dist/clientAnnex/zcap.js +2 -2
  30. package/dist/clientAnnex/zcap.js.map +1 -1
  31. package/dist/identity/agents.js +2 -2
  32. package/dist/identity/agents.js.map +1 -1
  33. package/dist/keys/index.d.ts +1 -1
  34. package/dist/keys/index.d.ts.map +1 -1
  35. package/dist/keys/index.js +1 -1
  36. package/dist/keys/index.js.map +1 -1
  37. package/dist/keys/userKeyRoster.d.ts +32 -2
  38. package/dist/keys/userKeyRoster.d.ts.map +1 -1
  39. package/dist/keys/userKeyRoster.js +40 -2
  40. package/dist/keys/userKeyRoster.js.map +1 -1
  41. package/dist/recovery/recoveryDelegation.d.ts +4 -1
  42. package/dist/recovery/recoveryDelegation.d.ts.map +1 -1
  43. package/dist/recovery/recoveryDelegation.js +4 -2
  44. package/dist/recovery/recoveryDelegation.js.map +1 -1
  45. package/dist/recovery/recoveryWebvh.d.ts +42 -2
  46. package/dist/recovery/recoveryWebvh.d.ts.map +1 -1
  47. package/dist/recovery/recoveryWebvh.js +160 -18
  48. package/dist/recovery/recoveryWebvh.js.map +1 -1
  49. package/dist/unlock/index.d.ts +2 -2
  50. package/dist/unlock/index.d.ts.map +1 -1
  51. package/dist/unlock/index.js +1 -1
  52. package/dist/unlock/index.js.map +1 -1
  53. package/dist/unlock/retire.d.ts +62 -9
  54. package/dist/unlock/retire.d.ts.map +1 -1
  55. package/dist/unlock/retire.js +59 -5
  56. package/dist/unlock/retire.js.map +1 -1
  57. package/dist/unlock/standingWebvh.d.ts +131 -21
  58. package/dist/unlock/standingWebvh.d.ts.map +1 -1
  59. package/dist/unlock/standingWebvh.js +239 -54
  60. package/dist/unlock/standingWebvh.js.map +1 -1
  61. package/dist/webvh/didWebvh.d.ts +3 -2
  62. package/dist/webvh/didWebvh.d.ts.map +1 -1
  63. package/dist/webvh/didWebvh.js +7 -5
  64. package/dist/webvh/didWebvh.js.map +1 -1
  65. package/dist/webvh/index.d.ts +2 -2
  66. package/dist/webvh/index.d.ts.map +1 -1
  67. package/dist/webvh/index.js +1 -1
  68. package/dist/webvh/index.js.map +1 -1
  69. package/dist/webvh/keyAgreement.d.ts +25 -0
  70. package/dist/webvh/keyAgreement.d.ts.map +1 -1
  71. package/dist/webvh/keyAgreement.js +24 -0
  72. package/dist/webvh/keyAgreement.js.map +1 -1
  73. package/dist/webvh/listClients.d.ts +7 -5
  74. package/dist/webvh/listClients.d.ts.map +1 -1
  75. package/dist/webvh/listClients.js +7 -5
  76. package/dist/webvh/listClients.js.map +1 -1
  77. package/dist/webvh/revokeClient.d.ts +48 -0
  78. package/dist/webvh/revokeClient.d.ts.map +1 -1
  79. package/dist/webvh/revokeClient.js +85 -1
  80. package/dist/webvh/revokeClient.js.map +1 -1
  81. package/dist/webvh/zcap.js +3 -3
  82. package/dist/webvh/zcap.js.map +1 -1
  83. package/package.json +5 -5
@@ -30,6 +30,10 @@ import { deriveNextKeyHash } from '@interop/did-method-webvh';
30
30
  import { hkdf } from '@noble/hashes/hkdf.js';
31
31
  import { sha256 } from '@noble/hashes/sha2.js';
32
32
  import { effectiveParameters, relationIds, updateKeyMultibase } from '../webvh/didWebvh.js';
33
+ import { ladderVmIds, listEnrolledWebvhClients } from '../webvh/listClients.js';
34
+ import { credentialKeyAgreementMethods, resolvedKeyAgreementMethods } from '../webvh/keyAgreement.js';
35
+ import { survivingClientKeyProtection } from '../webvh/revokeClient.js';
36
+ import { log as logger } from '../log.js';
33
37
  import { LADDER_SEED_BYTES } from '../unlock/unlockRecord.js';
34
38
  /**
35
39
  * The HKDF salt for rung derivation and the per-rung info prefix (the rung
@@ -39,9 +43,9 @@ import { LADDER_SEED_BYTES } from '../unlock/unlockRecord.js';
39
43
  const LADDER_SALT = 'freewallet/unlock/update-ladder/v1';
40
44
  const LADDER_RUNG_INFO_PREFIX = 'rung/';
41
45
  /**
42
- * The info label of the ladder VM -- the stable sibling key published in the
43
- * account document while the account has no enrolled client. One salt
44
- * for everything ladder-seed-derived, with the info namespace doing the
46
+ * The info label of the ladder VM -- the stable sibling key a standing
47
+ * credential publishes in the account document for as long as it stands. One
48
+ * salt for everything ladder-seed-derived, with the info namespace doing the
45
49
  * separation: `vm` can never collide with a `rung/<n>` label. Permanent.
46
50
  */
47
51
  const LADDER_VM_INFO = 'vm';
@@ -135,10 +139,15 @@ export async function ladderRung({ ladderSeed, index }) {
135
139
  * published verbatim in the account document (the seed is random, so the
136
140
  * hash-commitment rule permits it) and stable across rung spends, so a
137
141
  * delegation it signed survives every ladder advance. It carries the
138
- * ladder-anchored window's document-visible authority (`assertionMethod` and
142
+ * credential's document-visible authority (`assertionMethod` and
139
143
  * `capabilityDelegation`), while update authority stays on the rungs -- the
140
144
  * two roles never share a key.
141
145
  *
146
+ * Its life is the credential's: the VM is installed in the entry that makes
147
+ * the credential standing (`publishUnlockKey`) and struck in the entry that
148
+ * retires it (`removeUnlockKey`). Enrollment never touches it, so several
149
+ * VMs stand on an account with several standing credentials.
150
+ *
142
151
  * Because the key is derived, removing its verification method is never the
143
152
  * terminal remedy: a later reinstall republishes the same key under the same
144
153
  * id, and any still-unexpired delegation it signed resumes verifying the
@@ -311,6 +320,549 @@ function credentialSurvives({ entry, vmId }) {
311
320
  // commitment.
312
321
  return relationIds(entry.state.keyAgreement).includes(vmId);
313
322
  }
323
+ /**
324
+ * The credential-class `keyAgreement` verification-method ids an entry
325
+ * INTRODUCES: those its document publishes and the previous entry's document
326
+ * did not. Credential-class means account-controlled
327
+ * (`credentialKeyAgreementMethods`), so an enrolled client's marked twin
328
+ * never counts. The co-introduction arm of the ladder-VM attribution reads
329
+ * this and refuses to act unless the answer is exactly this credential.
330
+ *
331
+ * @param options {object}
332
+ * @param options.doc {KeyAgreementDocument} the entry's document
333
+ * @param [options.prevDoc] {KeyAgreementDocument} the previous entry's
334
+ * @param options.did {string} the account DID
335
+ * @returns {string[]} in document order
336
+ */
337
+ function introducedCredentialKeys({ doc, prevDoc, did }) {
338
+ const before = new Set((prevDoc ? credentialKeyAgreementMethods({ doc: prevDoc, did }) : []).map(method => method.id));
339
+ return credentialKeyAgreementMethods({ doc, did })
340
+ .map(method => method.id)
341
+ .filter((id) => id !== undefined && !before.has(id));
342
+ }
343
+ /**
344
+ * The one pre-pass over the log's effective parameters: per-entry facts, plus
345
+ * the commit index both walks project their positional questions through. The
346
+ * forward walk asks what an entry added and who signed it; the backward walk
347
+ * asks where a hash came from and what stood beside it there.
348
+ *
349
+ * @param options {object}
350
+ * @param options.log {DIDLog}
351
+ * @param options.params {Array<{ updateKeys: string[], nextKeyHashes: string[] }>}
352
+ * the log's effective parameters, entry by entry
353
+ * @returns {{ facts: LadderEntryFacts[], commitIndex: Map<string, LadderCommitOrigin> }}
354
+ */
355
+ function indexLadderLog({ log, params }) {
356
+ const facts = [];
357
+ const commitIndex = new Map();
358
+ let prevUpdateKeys = new Set();
359
+ let prevHashes = new Set();
360
+ for (const [entryIndex, entry] of params.entries()) {
361
+ const currentUpdateKeys = new Set(entry.updateKeys);
362
+ const addedKeys = entry.updateKeys.filter(key => !prevUpdateKeys.has(key));
363
+ const removedKeys = [...prevUpdateKeys].filter(key => !currentUpdateKeys.has(key));
364
+ const addedHashes = entry.nextKeyHashes.filter(hash => !prevHashes.has(hash));
365
+ const signers = entrySigners({ entry: log[entryIndex] });
366
+ addedHashes.forEach((hash, at) => {
367
+ if (!commitIndex.has(hash)) {
368
+ commitIndex.set(hash, { entryIndex, at });
369
+ }
370
+ });
371
+ facts.push({ addedKeys, removedKeys, addedHashes, signers });
372
+ prevUpdateKeys = currentUpdateKeys;
373
+ prevHashes = new Set(entry.nextKeyHashes);
374
+ }
375
+ return { facts, commitIndex };
376
+ }
377
+ /**
378
+ * Walks the ladder BACKWARDS from the anchor, recovering the rungs the anchor
379
+ * has already climbed past. Run only when no ladder seed is in hand, which is
380
+ * the case the anchor's staleness would otherwise decide: the one writer that
381
+ * advances a recorded anchor does so after a self-enrollment, and every entry
382
+ * the spent rungs signed would then be invisible to the forward walk.
383
+ *
384
+ * Each step reads one hash's origin and asks which of the format's two
385
+ * positional rules put it there
386
+ * (`decisions/0007-ladder-reveal-hash-order.md`). Both rules are read here in
387
+ * reverse, so the shapes the emitters produce forwards are the shapes this
388
+ * recognizes backwards.
389
+ *
390
+ * The LAST-POSITION rule is a climb. A hash appended last among its entry's
391
+ * additions is the committer's own next commitment, so the key that signed
392
+ * that entry is the rung before it. The step is taken only when the entry
393
+ * authorized exactly one key and that key signed the entry, which makes it a
394
+ * prerotation reveal rather than mere adjacency, and only when the credential
395
+ * itself still stands in the entry's document.
396
+ *
397
+ * The ADJACENCY rule is a handover. A hash not in last position sits beside
398
+ * the rung committed immediately before it, and that predecessor is revealed
399
+ * later by the entry that retires the committer. The step is taken only when
400
+ * such a revealing entry exists and the credential stands in ITS document.
401
+ *
402
+ * What each guard protects. The credential-membership test stops the walk at a
403
+ * plain client genesis, at the enrolled-client bind that carries no member of
404
+ * ours yet, and at the spent recovery code whose reveal entry commits the
405
+ * REPLACEMENT code's hash last -- without it the replacement's retirement
406
+ * would recover the spent code's key and go on to strike the fresh
407
+ * credential's rungs. The single-self-revealing-key test stops it at the bind
408
+ * entry an enrolled client signs, which authorizes no key of its own, so the
409
+ * binding client's update key is never recovered as a rung. The strictly
410
+ * decreasing entry cursor and the already-recovered test keep the walk finite
411
+ * and acyclic.
412
+ *
413
+ * @param options {object}
414
+ * @param options.log {DIDLog}
415
+ * @param options.facts {LadderEntryFacts[]} from {@link indexLadderLog}
416
+ * @param options.commitIndex {Map<string, LadderCommitOrigin>} likewise
417
+ * @param options.anchorHash {string} `hash(anchorKeyMultibase)`
418
+ * @param options.credentialVmId {string} the credential's own `keyAgreement`
419
+ * verification-method id
420
+ * @param options.maxScan {number} how many rungs to walk back
421
+ * @returns {Promise<Array<{ key: string, hash: string }>>} the recovered
422
+ * rungs, nearest the anchor first
423
+ */
424
+ async function recoverEarlierRungs({ log, facts, commitIndex, anchorHash, credentialVmId, maxScan }) {
425
+ const recovered = [];
426
+ const seenHashes = new Set([anchorHash]);
427
+ let cursorHash = anchorHash;
428
+ let cursorEntry = Number.POSITIVE_INFINITY;
429
+ for (let step = 0; step < maxScan; step++) {
430
+ const origin = commitIndex.get(cursorHash);
431
+ if (origin === undefined || origin.entryIndex >= cursorEntry) {
432
+ return recovered;
433
+ }
434
+ const originFacts = facts[origin.entryIndex];
435
+ if (origin.at === originFacts.addedHashes.length - 1) {
436
+ // The last-position rule, read backwards: a climb.
437
+ const predecessor = originFacts.addedKeys[0];
438
+ if (originFacts.addedKeys.length !== 1 ||
439
+ predecessor === undefined ||
440
+ !originFacts.signers.includes(predecessor) ||
441
+ !credentialSurvives({
442
+ entry: log[origin.entryIndex],
443
+ vmId: credentialVmId
444
+ })) {
445
+ return recovered;
446
+ }
447
+ const hash = await deriveNextKeyHash(predecessor);
448
+ if (seenHashes.has(hash)) {
449
+ return recovered;
450
+ }
451
+ recovered.push({ key: predecessor, hash });
452
+ seenHashes.add(hash);
453
+ cursorHash = hash;
454
+ cursorEntry = origin.entryIndex;
455
+ continue;
456
+ }
457
+ // The adjacency rule, read backwards: a handover.
458
+ const partner = origin.at > 0 ? originFacts.addedHashes[origin.at - 1] : undefined;
459
+ if (partner === undefined || seenHashes.has(partner)) {
460
+ return recovered;
461
+ }
462
+ const reveal = await findRungReveal({
463
+ facts,
464
+ after: origin.entryIndex,
465
+ committerSigners: originFacts.signers,
466
+ hash: partner
467
+ });
468
+ if (reveal === undefined ||
469
+ !credentialSurvives({
470
+ entry: log[reveal.entryIndex],
471
+ vmId: credentialVmId
472
+ })) {
473
+ return recovered;
474
+ }
475
+ recovered.push({ key: reveal.key, hash: partner });
476
+ seenHashes.add(partner);
477
+ cursorHash = partner;
478
+ cursorEntry = origin.entryIndex;
479
+ }
480
+ return recovered;
481
+ }
482
+ /**
483
+ * The entry that reveals a committed hash's key while retiring one of the
484
+ * signers that committed it -- the handover the adjacency rule describes.
485
+ * Earliest such entry wins, since a key is authorized once.
486
+ *
487
+ * @param options {object}
488
+ * @param options.facts {LadderEntryFacts[]}
489
+ * @param options.after {number} search entries strictly after this index
490
+ * @param options.committerSigners {string[]} the committing entry's signers
491
+ * @param options.hash {string} the committed hash whose key is sought
492
+ * @returns {Promise<{ entryIndex: number, key: string } | undefined>}
493
+ */
494
+ async function findRungReveal({ facts, after, committerSigners, hash }) {
495
+ for (let entryIndex = after + 1; entryIndex < facts.length; entryIndex++) {
496
+ const candidate = facts[entryIndex];
497
+ if (!candidate.removedKeys.some(key => committerSigners.includes(key))) {
498
+ continue;
499
+ }
500
+ for (const key of candidate.addedKeys) {
501
+ if ((await deriveNextKeyHash(key)) === hash) {
502
+ return { entryIndex, key };
503
+ }
504
+ }
505
+ }
506
+ return undefined;
507
+ }
508
+ /**
509
+ * Thrown when an edit's `nextKeyHashes` would come out empty. An empty list
510
+ * switches prerotation off in did:webvh, so an entry that struck every
511
+ * commitment would leave the account with no staged key at all. Every ceremony
512
+ * that strikes hashes commits its own successors in the same entry, so the
513
+ * list is non-empty by construction; this is the assertion that says so.
514
+ */
515
+ export class NextKeyHashesEmptyError extends Error {
516
+ constructor(message) {
517
+ super(message);
518
+ this.name = 'NextKeyHashesEmptyError';
519
+ }
520
+ }
521
+ /**
522
+ * Refuses to publish an entry whose `nextKeyHashes` came out empty.
523
+ *
524
+ * @param options {object}
525
+ * @param options.nextKeyHashes {string[]}
526
+ * @param options.ceremony {string} named in the refusal
527
+ * @returns {string[]} the list, unchanged
528
+ */
529
+ export function assertNextKeyHashesRemain({ nextKeyHashes, ceremony }) {
530
+ if (nextKeyHashes.length === 0) {
531
+ throw new NextKeyHashesEmptyError(`did:webvh: ${ceremony} would publish an entry committing no next key ` +
532
+ 'hash, which switches prerotation off; the entry was not published.');
533
+ }
534
+ return nextKeyHashes;
535
+ }
536
+ /**
537
+ * The enrolled-client members an entry INTRODUCES: new `capabilityInvocation`
538
+ * ids, and new `keyAgreement` methods the account DID does not control (a
539
+ * client's marked twin). The bind-anchor read refuses any entry that
540
+ * introduces one, because an entry publishing a client also publishes that
541
+ * client's update key, and reading that key as a credential's rung 0 would
542
+ * anchor the walk on a surviving client.
543
+ *
544
+ * @param options {object}
545
+ * @param options.doc {KeyAgreementDocument} the entry's document
546
+ * @param [options.prevDoc] {KeyAgreementDocument} the previous entry's
547
+ * @param options.did {string} the account DID
548
+ * @returns {boolean}
549
+ */
550
+ function introducesEnrolledClient({ doc, prevDoc, did }) {
551
+ const beforeInvocation = new Set(relationIds(prevDoc?.capabilityInvocation));
552
+ if (relationIds(doc.capabilityInvocation).some(id => !beforeInvocation.has(id))) {
553
+ return true;
554
+ }
555
+ const markedIds = (entryDoc) => {
556
+ if (entryDoc === undefined) {
557
+ return new Set();
558
+ }
559
+ const credential = new Set(credentialKeyAgreementMethods({ doc: entryDoc, did }).map(method => method.id));
560
+ return new Set(resolvedKeyAgreementMethods({ doc: entryDoc })
561
+ .map(method => method.id)
562
+ .filter((id) => id !== undefined && !credential.has(id)));
563
+ };
564
+ const before = markedIds(prevDoc);
565
+ return [...markedIds(doc)].some(id => !before.has(id));
566
+ }
567
+ /**
568
+ * The anchor a credential's ladder walk starts from when the caller holds no
569
+ * recorded update key -- the log-only anchoring a cold browser needs. The
570
+ * credential's own `keyAgreement` member id is the anchor: the entry that
571
+ * FIRST introduced that member is the credential's bind entry, and what that
572
+ * entry did to the standing parameters names rung 0.
573
+ *
574
+ * Two shapes are read, both fail-closed:
575
+ *
576
+ * - the entry authorized exactly one update key and that key signed it (a
577
+ * prerotation reveal, the ladder-anchored genesis shape), so rung 0 is that
578
+ * key outright;
579
+ * - the entry authorized no key of its own and newly committed exactly one
580
+ * hash (the `publishUnlockKey` bind an enrolled client signs, and the
581
+ * recovery-code issuance sharing it), so rung 0's hash is that hash.
582
+ *
583
+ * Anything else is ambiguous and returns `undefined`, which the callers report
584
+ * as unclaimed rather than acting on. The reachable ambiguity is a bind entry
585
+ * introducing more than one credential-class member: a recovery
586
+ * add-and-retire entry introduces the fresh credential and the replacement
587
+ * code together, so neither is anchorable this way.
588
+ *
589
+ * @param options {object}
590
+ * @param options.log {DIDLog} a resolved, caller-verified log
591
+ * @param options.credentialVmId {string} the credential's `keyAgreement`
592
+ * verification-method id
593
+ * @returns {Promise<{ anchorKeyMultibase?: string, anchorHash?: string } |
594
+ * undefined>}
595
+ */
596
+ export async function credentialLadderAnchor({ log, credentialVmId }) {
597
+ const { facts } = indexLadderLog({ log, params: effectiveParameters(log) });
598
+ return resolveBindAnchor({ log, facts, credentialVmId });
599
+ }
600
+ /**
601
+ * The synchronous core of {@link credentialLadderAnchor}, over a pre-pass the
602
+ * caller already ran.
603
+ *
604
+ * @param options {object}
605
+ * @param options.log {DIDLog}
606
+ * @param options.facts {LadderEntryFacts[]} from {@link indexLadderLog}
607
+ * @param options.credentialVmId {string}
608
+ * @returns {{ anchorKeyMultibase?: string, anchorHash?: string } | undefined}
609
+ */
610
+ function resolveBindAnchor({ log, facts, credentialVmId }) {
611
+ const did = credentialVmId.split('#')[0];
612
+ if (did === undefined || did === '') {
613
+ return undefined;
614
+ }
615
+ // Every update key the log attributes to a client the final document still
616
+ // lists, so the self-signed arm can refuse one outright. A client whose
617
+ // active key the log cannot attribute leaves that arm unable to refuse
618
+ // anything, so no anchor is named at all.
619
+ const enrolledClients = listEnrolledWebvhClients({ log });
620
+ if (enrolledClients.some(client => client.updateKeyMultibase === undefined)) {
621
+ return undefined;
622
+ }
623
+ const enrolledClientKeys = new Set(enrolledClients
624
+ .map(client => client.updateKeyMultibase)
625
+ .filter((key) => key !== undefined));
626
+ let prevDoc;
627
+ for (const [index, entry] of log.entries()) {
628
+ const doc = entry.state;
629
+ if (doc === undefined) {
630
+ continue;
631
+ }
632
+ const introduced = introducedCredentialKeys({ doc, prevDoc, did });
633
+ const prevDocBefore = prevDoc;
634
+ prevDoc = doc;
635
+ if (!introduced.includes(credentialVmId)) {
636
+ continue;
637
+ }
638
+ // The bind entry. More than one credential-class member introduced here
639
+ // and nothing below can say which addition is whose.
640
+ if (introduced.length !== 1) {
641
+ return undefined;
642
+ }
643
+ const bind = facts[index];
644
+ if (bind === undefined) {
645
+ return undefined;
646
+ }
647
+ // The fourth condition: an entry that also publishes an enrolled client
648
+ // names no credential's rung. The remembered recovery's add-and-retire
649
+ // entry is exactly this shape -- the new client's key-agreement method is
650
+ // client-marked, so the credential-class count above sees only the
651
+ // replacement code and the ambiguity guard does not fire, while the one
652
+ // key the entry authorizes is the CLIENT's update key.
653
+ if (introducesEnrolledClient({
654
+ doc: doc,
655
+ prevDoc: prevDocBefore,
656
+ did
657
+ })) {
658
+ return undefined;
659
+ }
660
+ const revealed = bind.addedKeys[0];
661
+ if (bind.addedKeys.length === 1 &&
662
+ revealed !== undefined &&
663
+ bind.signers.includes(revealed) &&
664
+ // Belt and braces beside the condition above: never anchor on a key the
665
+ // log attributes to an enrolled client, whichever entry published it.
666
+ !enrolledClientKeys.has(revealed)) {
667
+ return { anchorKeyMultibase: revealed };
668
+ }
669
+ if (bind.addedKeys.length === 0 && bind.addedHashes.length === 1) {
670
+ // No enrolled-client check of its own: this arm reads a hash rather
671
+ // than a key, and no ceremony fuses a credential bind with a client's
672
+ // hash commitment.
673
+ return { anchorHash: bind.addedHashes[0] };
674
+ }
675
+ return undefined;
676
+ }
677
+ return undefined;
678
+ }
679
+ /**
680
+ * Whether one retiring credential's rung inventory can be claimed from the log
681
+ * at all: its bind entry must name an anchor, and the walk from that anchor
682
+ * must not refuse. This is the log-only test, so it answers the same before
683
+ * and after the retirement entry lands -- which is what lets a resumed run
684
+ * report the same unclaimed set the first run reported.
685
+ *
686
+ * @param options {object}
687
+ * @param options.log {DIDLog}
688
+ * @param options.credentialVmId {string}
689
+ * @param options.maxScan {number}
690
+ * @returns {Promise<LadderStandingInventory | undefined>} the walk's result,
691
+ * or `undefined` when the credential cannot be claimed
692
+ */
693
+ async function claimLadderInventory({ log, credentialVmId, maxScan }) {
694
+ try {
695
+ return await attributeLadderInventory({ log, credentialVmId, maxScan });
696
+ }
697
+ catch {
698
+ // An ambiguous anchor or an ambiguous history. Fail closed.
699
+ return undefined;
700
+ }
701
+ }
702
+ /**
703
+ * The strike a retirement entry ALREADY published, recomputed by re-running
704
+ * {@link attributeRetiredCredentialRungs} over the log as it stood just before
705
+ * that entry. A resumed ceremony reports what its first run reported this way,
706
+ * rather than through a second definition of "unclaimed" that could answer
707
+ * differently.
708
+ *
709
+ * The entry is located by the key it authorized: every ceremony that calls
710
+ * this detects its own completion by that key standing in `updateKeys`, and
711
+ * the entry that FIRST authorized it is the one to walk back to. A log that
712
+ * does not authorize the key, or authorizes it at the genesis entry, has no
713
+ * usable prefix and is refused: a caller that reached this had already seen
714
+ * the key authorized, so either shape is a caller defect rather than a
715
+ * state to answer for.
716
+ *
717
+ * @param options {object}
718
+ * @param options.log {DIDLog} the post-entry log
719
+ * @param options.authorizedKeyMultibase {string} the update key the entry
720
+ * authorized
721
+ * @param options.credentialVmIds {string[]} the credentials the entry
722
+ * retired, as the caller derived them from the log
723
+ * @param [options.protectedHashes] {string[]} the same set the first run
724
+ * passed
725
+ * @param [options.protectedKeys] {string[]} likewise
726
+ * @param [options.maxScan] {number}
727
+ * @returns {Promise<{ struckHashes: string[], struckKeys: string[],
728
+ * unclaimedCredentialVmIds: string[] }>}
729
+ */
730
+ export async function retiredCredentialRungsBeforeKey({ log, authorizedKeyMultibase, credentialVmIds, protectedHashes = [], protectedKeys = [], maxScan = LADDER_MAX_SCAN }) {
731
+ const params = effectiveParameters(log);
732
+ const entryIndex = params.findIndex(entry => entry.updateKeys.includes(authorizedKeyMultibase));
733
+ if (entryIndex <= 0) {
734
+ throw new Error(`retiredCredentialRungsBeforeKey: the log ${entryIndex < 0 ? 'never authorizes' : 'authorizes at genesis'} update key ${authorizedKeyMultibase}, so no pre-entry prefix exists`);
735
+ }
736
+ return attributeRetiredCredentialRungs({
737
+ log: log.slice(0, entryIndex),
738
+ credentialVmIds,
739
+ protectedHashes,
740
+ protectedKeys,
741
+ maxScan
742
+ });
743
+ }
744
+ /**
745
+ * What a full retirement must strike from the standing parameters for a set of
746
+ * credentials being retired in one entry: their committed rung hashes, and any
747
+ * rung of theirs standing revealed in `updateKeys`. Each credential is
748
+ * anchored from the log alone ({@link credentialLadderAnchor}), so a cold
749
+ * browser holding no registry and no seed can still strike them.
750
+ *
751
+ * The bias is under-striking, deliberately. Over-striking is silent and
752
+ * unhealable -- a surviving credential or client keeps its verification
753
+ * methods and its roster wrap, and only fails when someone finally uses it --
754
+ * while under-striking leaves a committed rung a retired credential's holder
755
+ * could reveal, which the report names. Five things keep it that way:
756
+ *
757
+ * - a credential whose anchor is ambiguous or whose walk refuses is reported
758
+ * as unclaimed and nothing of its is struck;
759
+ * - only what the walk positively claims is a candidate;
760
+ * - a hash or key the caller names as its own (`protectedHashes` /
761
+ * `protectedKeys`, the successors the entry itself commits) is dropped;
762
+ * - every SURVIVING enrolled client's active update key, its carry-over hash
763
+ * and its staged hash are dropped, whatever the walk claimed
764
+ * ({@link survivingClientKeyProtection}). That guard is structural rather
765
+ * than a property of the walk, because a mis-anchored walk landing on a
766
+ * client's key would otherwise end that client's ability to extend the
767
+ * account log for good. The walks therefore run FIRST, and the hashes they
768
+ * claimed are passed to the protection as known-latent, so a retiring
769
+ * credential's own rung cannot make a client's staged attribution ambiguous
770
+ * and get itself protected as a candidate;
771
+ * - a listed enrolled client whose ACTIVE update key the log cannot attribute
772
+ * withholds the WHOLE strike: nothing is struck and every credential is
773
+ * reported, since the structural guard cannot say what that client holds.
774
+ *
775
+ * The report is a not-fully-retired report rather than a nothing-happened one.
776
+ * A credential appears on `unclaimedCredentialVmIds` when its walk refused,
777
+ * when it claimed nothing, AND when any single hash or key it claimed was
778
+ * withheld by one of the kept sets. The rest of that credential's claims are
779
+ * still struck; what the caller must not be told is that a partial retirement
780
+ * was a whole one.
781
+ *
782
+ * @param options {object}
783
+ * @param options.log {DIDLog} a resolved, caller-verified log, read BEFORE
784
+ * the entry is built
785
+ * @param options.credentialVmIds {string[]} the retiring credentials'
786
+ * `keyAgreement` verification-method ids
787
+ * @param [options.protectedHashes] {string[]} hashes the entry itself
788
+ * commits, never struck
789
+ * @param [options.protectedKeys] {string[]} update keys the entry itself
790
+ * authorizes, never struck
791
+ * @param [options.maxScan] {number} the ladder walk's bound
792
+ * @returns {Promise<{ struckHashes: string[], struckKeys: string[],
793
+ * unclaimedCredentialVmIds: string[] }>}
794
+ */
795
+ export async function attributeRetiredCredentialRungs({ log, credentialVmIds, protectedHashes = [], protectedKeys = [], maxScan = LADDER_MAX_SCAN }) {
796
+ // The walks first, so what they claimed can be vouched for as latent when
797
+ // the surviving clients' staged hashes are attributed below.
798
+ const claims = new Map();
799
+ const claimedHashes = new Set();
800
+ for (const credentialVmId of credentialVmIds) {
801
+ const inventory = await claimLadderInventory({
802
+ log,
803
+ credentialVmId,
804
+ maxScan
805
+ });
806
+ claims.set(credentialVmId, inventory);
807
+ for (const hash of inventory?.committedHashes ?? []) {
808
+ claimedHashes.add(hash);
809
+ }
810
+ }
811
+ // The structural guard, resolved once from the log rather than per
812
+ // credential: what the account's surviving enrolled clients hold.
813
+ const surviving = await survivingClientKeyProtection({
814
+ log,
815
+ retiredVmIds: credentialVmIds,
816
+ knownLatentHashes: [...claimedHashes]
817
+ });
818
+ if (surviving.ambiguous.length > 0) {
819
+ logger.warn('Withholding a credential rung strike: an enrolled client whose ' +
820
+ 'active update key the log cannot attribute would be unprotected', { clients: surviving.ambiguous });
821
+ return {
822
+ struckHashes: [],
823
+ struckKeys: [],
824
+ unclaimedCredentialVmIds: [...credentialVmIds]
825
+ };
826
+ }
827
+ const keptHashes = new Set([...protectedHashes, ...surviving.hashes]);
828
+ const keptKeys = new Set([...protectedKeys, ...surviving.keys]);
829
+ const struckHashes = new Set();
830
+ const struckKeys = new Set();
831
+ const unclaimedCredentialVmIds = [];
832
+ for (const credentialVmId of credentialVmIds) {
833
+ const inventory = claims.get(credentialVmId);
834
+ if (inventory === undefined) {
835
+ unclaimedCredentialVmIds.push(credentialVmId);
836
+ continue;
837
+ }
838
+ let withheld = false;
839
+ let struckAny = false;
840
+ for (const hash of inventory.committedHashes) {
841
+ if (keptHashes.has(hash)) {
842
+ withheld = true;
843
+ continue;
844
+ }
845
+ struckHashes.add(hash);
846
+ struckAny = true;
847
+ }
848
+ for (const key of inventory.revealedKeys) {
849
+ if (keptKeys.has(key)) {
850
+ withheld = true;
851
+ continue;
852
+ }
853
+ struckKeys.add(key);
854
+ struckAny = true;
855
+ }
856
+ if (withheld || !struckAny) {
857
+ unclaimedCredentialVmIds.push(credentialVmId);
858
+ }
859
+ }
860
+ return {
861
+ struckHashes: [...struckHashes],
862
+ struckKeys: [...struckKeys],
863
+ unclaimedCredentialVmIds
864
+ };
865
+ }
314
866
  /**
315
867
  * Attributes a ladder's FULL standing inventory from the log -- the retirement
316
868
  * counterpart of {@link attributeLadderRung}, which recovers only the single
@@ -351,35 +903,115 @@ function credentialSurvives({ entry, vmId }) {
351
903
  * commitment in that position instead, and striking that would leave the
352
904
  * replacement unusable and unhealable;
353
905
  * - a claim or revealed key that later leaves the parameters without a
354
- * completion was struck by some other edit and simply stops standing.
906
+ * completion was struck by some other edit and simply stops standing;
907
+ * - a ladder VM standing in the final document belongs to this ladder on
908
+ * either of two arms, asked at the entry that PUBLISHED it (the entry at
909
+ * which the id appeared among the document's ladder VMs). The SIGNER arm:
910
+ * that entry was signed by a key this ladder accounted for at that point,
911
+ * which covers every install a ladder rung signs (the ladder-anchored
912
+ * genesis, the ladder-VM install, the transient recovery's add-and-retire
913
+ * entry). The CO-INTRODUCTION arm: that entry also introduced this
914
+ * credential's own `keyAgreement` member (`credentialVmId`), which is what
915
+ * reaches a bind entry an ENROLLED CLIENT signed -- the shape
916
+ * `publishUnlockKey` writes, whose signer is the binding client's update
917
+ * key rather than a rung. Three guards keep that arm from over-claiming:
918
+ * it needs `credentialVmId` in hand, the entry must introduce exactly ONE
919
+ * credential-class `keyAgreement` member (the account-controlled class,
920
+ * `credentialKeyAgreementMethods`; the transient recovery entry introduces
921
+ * two and is left to the signer arm), and the entry must introduce exactly
922
+ * ONE ladder VM. The question is anchored rather than free-standing: it
923
+ * answers "is this VM mine", and a VM no anchored ladder claims is
924
+ * identified by subtraction and left standing, since striking a key this
925
+ * ladder cannot show it owns would take out a surviving credential's.
926
+ * The COMMITMENT arm: that entry committed a hash the ladder knows a priori
927
+ * (the anchor's, a seed-derived rung's, or one the backward pre-pass
928
+ * recovered), introduced exactly one ladder VM, and introduced no OTHER
929
+ * credential's `keyAgreement` member. It reaches the reinstall an
930
+ * `establishStandingUnlock` re-run writes, which mints a fresh ladder seed
931
+ * for a credential whose member already stands, so neither of the other
932
+ * arms can see it. Like the co-introduction arm it needs `credentialVmId`
933
+ * in hand, since with no id the foreign-member guard would pass vacuously.
934
+ *
935
+ * The attribution is anchor-invariant across the shapes where each rung's
936
+ * hash was committed by an entry that also revealed the previous rung, or by
937
+ * a handover. The signer arm's key set is the anchor plus every earlier rung
938
+ * a backward pre-pass recovers from the log's own positional rules ({@link
939
+ * recoverEarlierRungs}, over `decisions/0007-ladder-reveal-hash-order.md`),
940
+ * so an entry a spent rung signed is attributed however far the anchor has
941
+ * since climbed. The ladder seed (`ladderSeed`) remains a shortcut and a
942
+ * cross-check rather than a requirement: it makes every rung's key and hash
943
+ * known outright and skips the pre-pass. A residue no arm can attribute is
944
+ * still released -- the retirement then strikes what the recorded inventory
945
+ * names and nothing more. More than one ladder reveal standing or arriving at
946
+ * once matches no legitimate history and fails closed ({@link
947
+ * LadderAttributionError}).
355
948
  *
356
- * With the ladder seed in hand (`ladderSeed`), every rung's key and hash are
357
- * additionally known a priori, so the attribution does not depend on the
358
- * anchor being current; without it, the walk is anchored on
359
- * `anchorKeyMultibase` and on `credentialVmId` alone, and a residue neither
360
- * can attribute is released -- the retirement then strikes what the recorded
361
- * inventory names and nothing more. More than one ladder reveal standing or
362
- * arriving at once matches no legitimate history and fails closed
363
- * ({@link LadderAttributionError}).
949
+ * One shape is out of reach seedlessly, and it is reachable today. The
950
+ * last-client transition strikes the ladder VM and reinstalls it in the same
951
+ * run (`forgetLastEnrolledClient` stage 1): same seed, the credential's
952
+ * member standing, the acting rung's hash still committed, and no hash added.
953
+ * A later self-enrollment then spends that already-revealed rung, so its
954
+ * reveal-and-commit entry authorizes no key while committing the next rung's
955
+ * hash, and the registry anchor advances to that next rung. The backward walk
956
+ * climbs from the anchor by asking which key the entry that committed its
957
+ * hash authorized; that entry authorized none, so the walk cannot name the
958
+ * rung that signed it, and the earlier rung and the reinstalled VM go
959
+ * unrecovered. A seedless retirement then reports the VM as `unclaimed` and
960
+ * leaves it standing. Tracked as WC-158.
961
+ *
962
+ * The anchor comes in three forms, and the walk is the same afterwards. A
963
+ * recorded update-key multibase (`anchorKeyMultibase`) is what a caller
964
+ * holding a registry entry passes. A hash (`anchorHash`) is the same anchor
965
+ * with the key withheld: the rung is picked up when the log reveals it, since
966
+ * the reveal test already matches on the commitment. With neither, and a
967
+ * `credentialVmId` in hand, the anchor is read off the credential's bind entry
968
+ * ({@link credentialLadderAnchor}) -- the cold-browser mode, where no registry
969
+ * is readable before the entry is written. An anchor the bind entry cannot
970
+ * name unambiguously refuses with {@link LadderAttributionError} rather than
971
+ * walking from a guess.
364
972
  *
365
973
  * @param options {object}
366
974
  * @param options.log {DIDLog} a resolved, caller-verified log
367
- * @param options.anchorKeyMultibase {string} the credential's recorded
975
+ * @param [options.anchorKeyMultibase] {string} the credential's recorded
368
976
  * update-key multibase (bind-time rung 0, or a refreshed later rung)
977
+ * @param [options.anchorHash] {string} the same anchor as a committed hash,
978
+ * for a caller that resolved one without the key
369
979
  * @param [options.ladderSeed] {Uint8Array} the credential's ladder seed,
370
980
  * when the caller holds it
371
981
  * @param [options.credentialVmId] {string} the credential's own
372
982
  * `keyAgreement` verification-method id, which tells a climb (the
373
983
  * credential stands afterwards) from a spend (its inventory goes in the same
374
- * entry)
984
+ * entry), which the ladder VM's co-introduction arm is anchored on, and
985
+ * which supplies the anchor itself when neither anchor form is passed
375
986
  * @param [options.maxScan] {number} seeded pre-derivation bound; defaults to
376
987
  * {@link LADDER_MAX_SCAN}
377
- * @returns {Promise<LadderStandingInventory>} what currently stands; both
378
- * arrays empty when the log carries nothing of the ladder any more
988
+ * @returns {Promise<LadderStandingInventory>} what currently stands; every
989
+ * array empty when the log carries nothing of the ladder any more
379
990
  */
380
- export async function attributeLadderInventory({ log, anchorKeyMultibase, ladderSeed, credentialVmId, maxScan = LADDER_MAX_SCAN }) {
381
- const anchorHash = await deriveNextKeyHash(anchorKeyMultibase);
382
- const ladderKeys = new Set([anchorKeyMultibase]);
991
+ export async function attributeLadderInventory({ log, anchorKeyMultibase, anchorHash: suppliedAnchorHash, ladderSeed, credentialVmId, maxScan = LADDER_MAX_SCAN }) {
992
+ const params = effectiveParameters(log);
993
+ const { facts, commitIndex } = indexLadderLog({ log, params });
994
+ // The anchor, in the caller's order of preference: the recorded update key,
995
+ // a hash the caller resolved itself, or -- holding neither, the cold-browser
996
+ // case -- the credential's own bind entry, read off the log.
997
+ let anchorKey = anchorKeyMultibase;
998
+ let anchorHash = suppliedAnchorHash;
999
+ if (anchorKey === undefined && anchorHash === undefined) {
1000
+ const resolved = credentialVmId === undefined
1001
+ ? undefined
1002
+ : resolveBindAnchor({ log, facts, credentialVmId });
1003
+ if (resolved === undefined) {
1004
+ throw new LadderAttributionError('The ladder walk was given no anchor, and the log does not name an ' +
1005
+ 'unambiguous bind entry for this credential; refusing to ' +
1006
+ 'attribute an ambiguous history.');
1007
+ }
1008
+ anchorKey = resolved.anchorKeyMultibase;
1009
+ anchorHash = resolved.anchorHash;
1010
+ }
1011
+ if (anchorHash === undefined) {
1012
+ anchorHash = await deriveNextKeyHash(anchorKey);
1013
+ }
1014
+ const ladderKeys = new Set(anchorKey === undefined ? [] : [anchorKey]);
383
1015
  // What the ladder knows a priori: the recorded key's hash and, with the
384
1016
  // seed in hand, every rung's. A claim outside this set is held on the
385
1017
  // evidence of the entry that committed it, and released again when a
@@ -395,33 +1027,45 @@ export async function attributeLadderInventory({ log, anchorKeyMultibase, ladder
395
1027
  ladderHashes.add(rungHash);
396
1028
  }
397
1029
  }
398
- const params = effectiveParameters(log);
1030
+ // Without the seed, the anchor alone would hide every entry a spent rung
1031
+ // signed. Recover those rungs from the log's positional rules first, and
1032
+ // treat them exactly as seed-derived ones: known a priori, on both sets.
1033
+ if (!ladderSeed && credentialVmId !== undefined) {
1034
+ const earlier = await recoverEarlierRungs({
1035
+ log,
1036
+ facts,
1037
+ commitIndex,
1038
+ anchorHash,
1039
+ credentialVmId,
1040
+ maxScan
1041
+ });
1042
+ for (const rung of earlier) {
1043
+ ladderKeys.add(rung.key);
1044
+ derivedHashes.add(rung.hash);
1045
+ ladderHashes.add(rung.hash);
1046
+ }
1047
+ }
399
1048
  let pending;
400
- let prevUpdateKeys = new Set();
401
1049
  let prevHashes = new Set();
402
- // Where each standing hash was FIRST committed: the entry's signers, the
403
- // hash appended immediately after it there, and whether that successor
404
- // closed the entry's additions. Read back at a reveal whose committing
405
- // entry the reveal itself retires (below).
406
- const firstCommit = new Map();
1050
+ // Whether the entry that published each ladder VM seen so far was signed by
1051
+ // a key this ladder accounted for at that point. Re-answered at every
1052
+ // publication, so a VM struck and later republished by another ladder is
1053
+ // attributed to whoever put the standing copy there.
1054
+ let prevLadderVmIds = new Set();
1055
+ let prevEntryDoc;
1056
+ const ladderVmClaims = new Map();
1057
+ // The account DID, read off the credential's own verification-method id:
1058
+ // the co-introduction arm needs it to tell a credential-class
1059
+ // `keyAgreement` member (controlled by the account) from an enrolled
1060
+ // client's marked twin.
1061
+ const credentialDid = credentialVmId?.split('#')[0];
407
1062
  for (const [index, entry] of params.entries()) {
408
1063
  const currentUpdateKeys = new Set(entry.updateKeys);
409
- const addedKeys = entry.updateKeys.filter(key => !prevUpdateKeys.has(key));
410
- const removedKeys = [...prevUpdateKeys].filter(key => !currentUpdateKeys.has(key));
411
- // Order-preserving on purpose: the completion transfer below reads the
412
- // claim committed immediately after the client's update-key hash as its
413
- // staged hash (the reveal entry's append order, a ratified convention).
414
- const addedHashes = entry.nextKeyHashes.filter(hash => !prevHashes.has(hash));
415
- const signers = entrySigners({ entry: log[index] });
416
- addedHashes.forEach((hash, at) => {
417
- if (!firstCommit.has(hash)) {
418
- firstCommit.set(hash, {
419
- signers,
420
- successor: addedHashes[at + 1],
421
- successorLast: at + 2 === addedHashes.length
422
- });
423
- }
424
- });
1064
+ // The pre-pass keeps these order-preserving on purpose: the completion
1065
+ // transfer below reads the claim committed immediately after the client's
1066
+ // update-key hash as its staged hash (the reveal entry's append order, a
1067
+ // ratified convention).
1068
+ const { addedKeys, removedKeys, addedHashes } = facts[index];
425
1069
  // Completion first: the pending revealed rung left `updateKeys`. When the
426
1070
  // same entry authorizes a key whose hash sits among the reveal's claims,
427
1071
  // the enrollment completed -- that hash and its successor (the client's
@@ -499,19 +1143,24 @@ export async function attributeLadderInventory({ log, anchorKeyMultibase, ladder
499
1143
  // credential survives or the seed derives it.
500
1144
  const keyHash = await deriveNextKeyHash(key);
501
1145
  const origin = prevHashes.has(keyHash)
502
- ? firstCommit.get(keyHash)
1146
+ ? commitIndex.get(keyHash)
503
1147
  : undefined;
504
- if (origin?.successor !== undefined &&
505
- !origin.successorLast &&
506
- origin.signers.some(signer => removedKeys.includes(signer))) {
507
- pending.claims.unshift(origin.successor);
508
- ladderHashes.add(origin.successor);
1148
+ const originFacts = origin === undefined ? undefined : facts[origin.entryIndex];
1149
+ if (origin !== undefined && originFacts !== undefined) {
1150
+ const successor = originFacts.addedHashes[origin.at + 1];
1151
+ const successorLast = origin.at + 2 === originFacts.addedHashes.length;
1152
+ if (successor !== undefined &&
1153
+ !successorLast &&
1154
+ originFacts.signers.some(signer => removedKeys.includes(signer))) {
1155
+ pending.claims.unshift(successor);
1156
+ ladderHashes.add(successor);
1157
+ }
509
1158
  }
510
1159
  }
511
1160
  else if (pending && ladderSigned({ entry: log[index], ladderKeys })) {
512
1161
  // The rung is still revealed AND signed this entry, so the hashes it
513
- // commits were committed under the rung's authority (the
514
- // ladder-anchored window's separate commit entry) and join its claims.
1162
+ // commits were committed under the rung's authority and join its
1163
+ // claims.
515
1164
  // Signature, not mere presence in `updateKeys`, is what attributes
516
1165
  // them: the rung's reveal outlives the ceremony that revealed it, so
517
1166
  // claiming everything committed afterwards would sweep up hashes of
@@ -522,7 +1171,46 @@ export async function attributeLadderInventory({ log, anchorKeyMultibase, ladder
522
1171
  ladderHashes.add(hash);
523
1172
  }
524
1173
  }
525
- prevUpdateKeys = currentUpdateKeys;
1174
+ // After the reveal branch, so a VM installed by the very entry that
1175
+ // reveals the rung signing it (the ladder-anchored genesis, the ladder-VM
1176
+ // install) is attributed to this ladder rather than missed.
1177
+ const entryDoc = log[index]?.state;
1178
+ const publishedVmIds = entryDoc ? ladderVmIds({ doc: entryDoc }) : [];
1179
+ const newVmIds = publishedVmIds.filter(vmId => !prevLadderVmIds.has(vmId));
1180
+ // The co-introduction arm, under its three guards (see the header): one
1181
+ // new ladder VM, one newly introduced credential-class `keyAgreement`
1182
+ // member, and that member is this credential's. It is what reaches the
1183
+ // bind entry an enrolled client signs, which no rung stands behind.
1184
+ const introduced = credentialDid !== undefined && entryDoc !== undefined
1185
+ ? introducedCredentialKeys({
1186
+ doc: entryDoc,
1187
+ prevDoc: prevEntryDoc,
1188
+ did: credentialDid
1189
+ })
1190
+ : [];
1191
+ const coIntroduced = credentialVmId !== undefined &&
1192
+ newVmIds.length === 1 &&
1193
+ introduced.length === 1 &&
1194
+ introduced[0] === credentialVmId;
1195
+ // The COMMITMENT arm (see the header): the entry committed a hash this
1196
+ // ladder knows a priori, published one ladder VM, and introduced no other
1197
+ // credential's member. `derivedHashes` rather than `ladderHashes`: a hash
1198
+ // held on the evidence of the entry that committed it is not proof of
1199
+ // ownership, and reading one here would claim a VM on a claim the
1200
+ // completion may yet release. It needs `credentialVmId` in hand for the
1201
+ // same reason the co-introduction arm does: with no id, `introduced` is
1202
+ // empty and the foreign-member guard would pass vacuously.
1203
+ const commitmentClaimed = credentialVmId !== undefined &&
1204
+ newVmIds.length === 1 &&
1205
+ addedHashes.some(hash => derivedHashes.has(hash)) &&
1206
+ introduced.every(id => id === credentialVmId);
1207
+ for (const vmId of newVmIds) {
1208
+ ladderVmClaims.set(vmId, coIntroduced ||
1209
+ commitmentClaimed ||
1210
+ ladderSigned({ entry: log[index], ladderKeys }));
1211
+ }
1212
+ prevLadderVmIds = new Set(publishedVmIds);
1213
+ prevEntryDoc = entryDoc;
526
1214
  prevHashes = new Set(entry.nextKeyHashes);
527
1215
  }
528
1216
  const final = params[params.length - 1] ?? {
@@ -531,7 +1219,8 @@ export async function attributeLadderInventory({ log, anchorKeyMultibase, ladder
531
1219
  };
532
1220
  return {
533
1221
  revealedKeys: final.updateKeys.filter(key => ladderKeys.has(key)),
534
- committedHashes: final.nextKeyHashes.filter(hash => ladderHashes.has(hash))
1222
+ committedHashes: final.nextKeyHashes.filter(hash => ladderHashes.has(hash)),
1223
+ ladderVmIds: [...prevLadderVmIds].filter(vmId => ladderVmClaims.get(vmId) === true)
535
1224
  };
536
1225
  }
537
1226
  //# sourceMappingURL=ladder.js.map