/*! * Copyright (c) 2026 Interop Alliance. All rights reserved. */ /** * Retiring a standing unlock credential: the ceremony behind "change my * passphrase" and "remove this passkey", run synchronously in an enrolled * client, in dependency order. A standing credential is not a stored string to * overwrite -- it holds a wrap in the user key roster and a `keyAgreement` * inventory in the account's did:webvh document -- so retiring one is a real * rotation, on the same stages the client-revocation cascade runs. * * 1. **The document inventory edit** (`removeUnlockKey`): the credential's * `keyAgreement` entry (verbatim key or commitment) and its committed * update-key hash leave the document in one log entry. That kills the * credential's latent self-enrollment authority -- with its rung * commitment gone, no reveal entry it could sign verifies -- and it is * what makes stage 2 converge: the roster resolver, backed by this * document, no longer keys the credential's entry. * 2. **The roster rotation and the collection fan-out** * (`rotateRosterToDocumentAndCascade`): the user key rotates off the * credential's wrap and every encrypted collection re-epochs onto the * fresh key, so writes stop landing under epochs the retired credential * could open. * * The order is load-bearing, and in that direction: the document removal * first means a run torn anywhere after it leaves the roster keying a * recipient the document no longer backs -- exactly the state the login-time * sweep detects and finishes. Torn the other way around, a rotation with the * inventory still standing would simply re-escrow the credential and look * healthy. * * No stage runs before the edit, and no sibling credential's record is * touched anywhere in the ceremony. Every unlock record's frame proof is * signed by its own credential's unlock identity key, and its bridge and * `delegatedClients` sibling delegations by that credential's own ladder VM * (`decisions/0019`), so the only record this strike can rot is the retired * credential's own -- which dies with the unlock Space the app deletes as * part of the change-method ceremony. A sibling credential's bridge is * refreshed by that credential's own login, on the three staleness axes * (expiry, the renewal window, a signer that left the document). * * The client annex reach is its own stage (1b, the injected * `retireClientAnnexInventory` closure), between the document edit and the * roster tail: a standing credential's annex rung-0 key and hash live in * the pointed generation's log, kept nowhere the account document edit can * reach, so without it a retired credential keeps annex-write authority * for the life of the generation. The closure runs strike-or-swap: a * dedicated strike entry signed by a distinct committed rung * (`retireClientAnnexRung`) where the ceremony holds one, else a fresh * generation minted from a surviving credential's seed and re-pointed under * account-log update authority (`swapClientAnnexGeneration`), the retired rung * dying with the old generation. It also owns retiring the credential's * `delegatedClients` sibling: no server revocation is possible or needed -- * the sibling delegation's record dies with the unlock Space the caller * deletes, and a swap (or the ordinary GC cadence) retires the generation it * targeted. Best-effort by contract, enforced by the ceremony itself: a * throw escaping the closure is caught here and reported as the `failed` * skip, so the roster rotation -- the ceremony's essential remedy -- always * runs. * * The edit carries the retirement gate (`decisions/0015`): a credential * retired here carries a ladder, so its ladder VM must be claimed -- by the * seed, or by the log's attribution -- before the edit strikes anything. A * claim that strikes nothing while ladder VMs stand unclaimed refuses with * `UnclaimedLadderVmRetirementError`, inside the edit before its entry * publishes, so the credential still stands and the log is unchanged. The * leftover the gate closes is a retired credential's VM standing under * `capabilityDelegation`, which could still sign a DELETE-only capability on * the account Space. Callers that establish a replacement before retiring * run `preflightUnlockCredentialRetirement` first, so the refusal lands * before establishment rather than in a torn state. The recovery-code * removal shares the edit but not the gate: a code carries no ladder VM to * claim. * * Convergence is the design: every stage detects its own completion from * durable state alone -- the inventory edit no-ops when the document is already * settled, the rotation no-ops once every current-epoch recipient is * document-backed, and a collection is stale exactly when its current epoch * names a non-current user key generation -- so a naive full re-run finishes * a torn ceremony and a healthy account writes nothing. * * The honest limitation is the cascade's: ciphertext the credential's holder * already fetched and decrypted stays readable, and old epochs stay open to * the user key generations the credential already delivered. Retirement stops * future reads. */ import type { IKeyAgreementKey } from '@interop/data-integrity-core'; import type { CollectionEncryption } from '@interop/was-client'; import type { EncryptionDescriptorStore } from '@interop/was-client/edv/core'; import { type CascadeCollections, type RosterSealReport, type UserKey, type UserKeyCascadeResult } from '../keys/index.js'; import type { WebvhIdStore } from '../webvh/index.js'; import type { AccountLogSigner } from '../webvh/accountEntry.js'; import { type LadderVmRemovalReport, type StandingUnlockKeys } from './standingWebvh.js'; /** * What a completed retirement reports: whether the roster actually rotated on * this run (a re-run of an already-complete retirement reports `false`), the * roster's seal-backstop report (present when the roster store is sealable and * the roster stage ran), the per-collection fan-out result, the document as * the inventory edit left it, the rotated key with the roster descriptor it * was read from, and the inventory edit's ladder VM report (`ladderVm`: what the edit struck, and * what stands unclaimed after it -- a seedless strike that claimed nothing * reports its credential's VM there rather than reading as clean). */ export interface UnlockCredentialRetirementResult { rotated: boolean; ladderVm: LadderVmRemovalReport; rosterSeal?: RosterSealReport; collections: UserKeyCascadeResult; document: object; userKey?: UserKey; rosterDescriptor?: CollectionEncryption; clientAnnex?: ClientAnnexInventoryRetirement; } /** * What the swap arm's revoke stage did with the old generation's embedded * delegation, carried on a `swapped` report so a caller retiring a suspect * credential can tell a swap that took the delegation off the account * (`revoked`, `expired`, `signer-gone`) from one that re-pointed past a * refusal the stage could not classify (`refused`: the delegation stands * unrevoked on a server that does not enforce pointer equality until the * collect fan-out's re-attempt succeeds) or had no delegation to revoke * (`no-delegation`, `log-absent`). The swap's own report type in the annex * (`ClientAnnexGenerationSwap`) names its `revoke` member with this union. */ export type ClientAnnexSwapRevokeOutcome = 'revoked' | 'expired' | 'signer-gone' | 'refused' | 'no-delegation' | 'log-absent'; /** * What the annex-inventory stage reports: `struck` (a strike entry dropped * the retired rung's key and hash), `swapped` (a fresh generation replaced * the old one wholesale; `revoke` says what became of the old generation's * delegation, see {@link ClientAnnexSwapRevokeOutcome}), `clean` (the pointed * generation held no inventory for the retired credential), or `skipped` * with the reason (`no-pointer`: the account has no annex inventory; * `no-ladder-seed`: the ceremony holds no seed that could strike or swap; * `failed`: the closure reported a failure, or threw and the ceremony caught * it). */ export interface ClientAnnexInventoryRetirement { action: 'struck' | 'swapped' | 'clean' | 'skipped'; reason?: 'no-pointer' | 'no-ladder-seed' | 'failed'; revoke?: ClientAnnexSwapRevokeOutcome; } /** * Retires one standing unlock credential from an account. See the module doc * for the order and the convergence story. Once the inventory edit lands, a * thrown later stage leaves durable state a naive re-run -- or the login-time * sweep -- converges from. * * @param options {object} * @param options.idStore {WebvhIdStore} the account's `id` collection store * @param options.signer {AccountLogSigner} who signs the inventory edit: * the retiring enrolled client's own did:webvh update-key seeds, or the * ACTING (surviving or successor) credential's ladder seed * @param options.unlockKeys {StandingUnlockKeys} the retired credential's * public inventory (its key-agreement publication and its recorded update * key, which the inventory edit treats as a ladder anchor rather than truth * -- see `removeUnlockKey`) * @param [options.ladderSeed] {Uint8Array} the retired credential's ladder * seed, when the ceremony holds the credential's secret; it strengthens the * ladder attribution, and it is what a retry supplies after a seedless run * refused with `UnclaimedLadderVmRetirementError` * @param [options.projectionStore] {object} an `id`-collection store the * caller may write through, passed straight to the inventory edit: the * post-strike `did:web` projection is PUT through it immediately before * that entry publishes, so a ladder-signed retirement does not leave * `did.json` naming the retired credential. Best-effort, and omitted the * behavior is unchanged (see `removeUnlockKey`) * @param [options.expectedDid] {string} the account DID from the caller's * stored account pointer; supplied, the inventory edit refuses a `did.jsonl` * resolving to any other account. The edit's own read inside its * conflict-retry loop runs under the store's chain-head pin, so a served * rollback or fork is refused before anything is published * @param [options.verb] {string} what the caller is doing, for the * pending-rotation refusal message (e.g. `'changing your passphrase'`) * @param options.rosterStore {EncryptionDescriptorStore} the * `key-map/user-key.jsonl` roster store * @param [options.userKey] {UserKey} this client's cached user key * @param options.clientKeyAgreementKey {IKeyAgreementKey} this client's own * (identity) key-agreement key -- its roster entry * @param [options.pinnedEpochId] {string} the locally pinned latest-seen * roster epoch * @param [options.onUserKeyAdopted] {Function} persists a rotated key: * called with `{ userKey, latestEpochId, descriptor }` after the roster read * and BEFORE the fan-out. The key and the epoch pin must persist atomically * @param options.collections {CascadeCollections} the fan-out's work. Every * collection store it hands back appends to that collection's governing * log, anchored at the POST-EDIT document, so its signer must be a key that * document still lists under `assertionMethod`. On a passphrase change that * is the NEW credential's ladder VM, never the retiring credential's, whose * ladder VM this ceremony's own inventory edit strikes; sign with the * retiring one and every collection append is refused, leaving the * collections keyed to the retired user key generation * @param [options.retireClientAnnexInventory] {Function} `({ document }) => * Promise` -- the annex reach (stage 1b in * the module doc), run against the post-edit document; a throw is caught * and reported as `{ action: 'skipped', reason: 'failed' }` * @param [options.onRotationAdopted] {Function} `({ userKey }) => * Promise` -- the live-session adoption of a rotated key, run last so * the session keeps operating without a re-login * @returns {Promise} */ export declare function retireUnlockCredential({ idStore, signer, unlockKeys, ladderSeed, projectionStore, expectedDid, verb, rosterStore, userKey, clientKeyAgreementKey, pinnedEpochId, onUserKeyAdopted, collections, retireClientAnnexInventory, onRotationAdopted }: { idStore: WebvhIdStore; signer: AccountLogSigner; unlockKeys: StandingUnlockKeys; ladderSeed?: Uint8Array; projectionStore?: Pick; expectedDid?: string; verb?: string; rosterStore: EncryptionDescriptorStore; userKey?: UserKey; clientKeyAgreementKey: IKeyAgreementKey; pinnedEpochId?: string | null; onUserKeyAdopted?: (adopted: { userKey: UserKey; latestEpochId: string; descriptor: CollectionEncryption; }) => Promise; collections: CascadeCollections; retireClientAnnexInventory?: (options: { document: object; }) => Promise; onRotationAdopted?: (rotation: { userKey: UserKey; }) => Promise; }): Promise; //# sourceMappingURL=retire.d.ts.map