/*! * Copyright (c) 2026 Interop Alliance. All rights reserved. */ /** * The LAST enrolled client's forget: the transition ceremony that takes an * account from one enrolled client to the client-less, ladder-anchored * state -- the same state a credential-anchored signup and a * transient recovery produce. Decision 0004's 2026-08-19 amendment fixes the * entry order, and it is forced twice over: the server's revocation endpoint * verifies a to-be-revoked capability's chain against the CURRENTLY resolved * document (so a ladder-signed chain revokes only while the ladder VM is * published), and the ladder VM carries no `capabilityInvocation` (so once * the client's verification method is gone, nothing can sign the revocation * POST at all). So: * * 1. **The strike-and-reinstall pair** * ({@link strikeLadderVmWebvh}, then {@link installLadderVmWebvh}): this * credential's own ladder VM leaves the document and returns in the next * entry, the client's whole inventory standing throughout. A ladder VM's * life is keyed to its credential rather than to the account's client * census, so the VM already stands when the transition starts and a bare * install would publish nothing; the reinstall is what carries the * ceremony's inventory-changing document version under the ceremony-tail * license. The strike reaches one credential's VM -- the id derives from * the ladder seed -- so another standing credential's VM is untouched. * The pair republishes the identical key under the identical id and * revokes nothing, so the revocation stage below is undisturbed by it and * every unexpired ladder-signed delegation keeps verifying. Its costs are * stated rather than hidden: the reinstall reveals a rung that stands in * `updateKeys`, as the single install already did, now over two entries; * and a run torn between the two leaves the account with no ladder VM * while the client still stands, which a re-run mends (the strike no-ops, * the install reinstalls). The pair runs only when the rotation is still * owed or the VM is missing, so a resumed run past the rotation publishes * neither entry. * * Both entries publish through `clientLogStore`, the store invoked under * the ENROLLED client's root authority, and not through the credential's * bridge delegation. The bridge is often signed by the very VM the strike * removes -- the readiness stage's renewal mints it as the ladder, and * stage 5 re-signs it as the ladder again -- so a bridge-invoked reinstall * would be authorized against the post-strike document under the * current-key-set rule and refused, leaving the account VM-less with every * ladder-signed delegation rotted and a re-run failing identically. The * client stands until stage 6 and holds root authority on the Space, so * its store is the one that carries the pair. Only the HTTP invocation * changes: both entries stay ladder-SIGNED. * 2. **The roster rotation**, ladder-signed, anchored at the reinstall entry, * HTTP-invoked under the still-standing client: the user key rotates off * this client's wrap in ONE append (the license's one-shot shape), read * back through the credential's standing wrap. The anchoring is this * ceremony's guarantee rather than the app's wiring: the shared * recipient-retiring tail (`retireRosterRecipientAndCascade`) sets the * roster store's minimum controller version from the post-reinstall log, * so a store resolving its controller from a cached pre-transition view * still lands the append at the reinstall version. Because the append's * signer is the ladder VM -- a key the post-removal document still lists * -- the roster log needs no seal repair afterwards, which matters on * an account where no enrolled client's login sweep will ever run again. * 3. **The collection fan-out**, still under this client's invocation * authority: every encrypted collection re-epochs onto the fresh key. * 4. **The generation-delegation revocations and replacement**: EVERY * delegation the annex log's history ever embedded that this * credential's ladder VM signed is revoked at the server (a renewal * inside the 30-day window can leave two, and a prior torn run's own * fresh one is another) -- closing the resurrection window a reinstalled * derived-key VM would otherwise reopen -- and a fresh ladder-signed * generation delegation replaces the embedded one when the house * staleness policy, read against a projected post-edit document, says it * is owed: this credential's ladder VM and the forgotten client are both * named retiring, which is every key this ceremony ends. The order is * revoke the historical members, replace, then revoke the embedded one. * The replacement is what keeps the account transient-login-reachable * after the transition; replace-before-revoke for the one live delegation * is what keeps a torn run from stranding the generation * delegation-less; and revoking the historical members before anything * is minted is what keeps a revocation the server persistently refuses * from adding a fresh doomed delegation per attempt (the first attempt * mints once before the embedded delegation's refusal is seen; every * re-run halts before minting). A delegation a * surviving sibling credential's ladder signed is left standing, since * the revocation loop never reaches it either. * 5. **The record re-bind seam** (`onBeforeRemoval`, required): the caller * re-signs the LOGIN credential's bridge and `delegatedClients` sibling * with the ladder VM and re-seals its unlock record with the credential * in hand (a full re-wrap, proof verified rather than settled). It is the * only unlock record this ceremony writes. Every OTHER credential's * record is signed by its own credential's ladder VM, which this ceremony * does not strike, so this transition rots no sibling record and re-seals * none (`decisions/0019`). Without the seam the removal entry would leave * the login credential's own bridge signed by the struck client's key on * an account with no enrolled client -- an account nothing can write to -- * which is why a call that omits the seam is refused before any read. * 6. **The removal entry** ({@link forgetLastWebvhClient}): the client's * whole document inventory out while the reinstalled ladder VM keeps the * account anchored. The post-removal `did:web` projection is PUT through * `clientLogStore` immediately BEFORE that entry, since the entry itself * is ladder-signed and writes `did.jsonl` alone while the client's root * authority ends at it -- without that the account would land client-less * with `did.json` still publishing the forgotten client's keys and nothing * left able to rewrite it. A run torn between the PUT and the entry leaves * `did.json` omitting a client the log still lists, which is fail-closed * for a `did:web` verifier and re-PUT by the re-run. The app's local wipe * runs after this ceremony returns. * * Torn-state map: every stage detects completion from durable state, so a * run torn anywhere before the removal entry reads as "not forgotten" and a * re-run converges. A tear BETWEEN the pair's two entries is the one worth * stating in full: it leaves the account with no ladder VM while the client * still stands, and the re-run's reinstall converges because it rides the * client's root authority rather than the credential's bridge -- the bridge * the strike may itself have rotted cannot authorize that write, and nothing * else on a one-client account could. Both entries of the pair are * idempotent and the pair is skipped once the rotation has landed, an * already-rotated roster skips the append (no second ladder-signed append is * ever attempted), the fan-out is staleness-driven, a re-POSTed revocation * reads was-client's genuine `AlreadyRevokedError` as success and a * delegation expired beyond the clock-skew margin skips the POST outright * (decision 0006's resume contract) -- any OTHER revocation failure instead * fails the stage and the whole ceremony, so a re-run resumes rather than * the transition declaring the resurrection window closed on a delegation * still standing -- the generation stage re-asks the same staleness policy * (a prior run's own fresh delegation reads as retiring, so a re-run revokes * it among the historical members and churns one delegation, while a * sibling-signed one churns none), and the record re-bind seam is * idempotent. Torn after the removal entry is the * finish-the-wipe state the app's next login maps. * * The honest limitation is the cascade's, as everywhere: ciphertext this * browser already fetched stays forensically recoverable from its storage, * and old epochs stay open to keys it already held. */ import type { DIDDoc, DIDLog } from '@interop/did-method-webvh'; import type { IKeyAgreementKey } from '@interop/data-integrity-core'; import type { CollectionEncryption, IDelegatedZcap } from '@interop/was-client'; import type { WebvhIdStore } from '../webvh/didWebvh.js'; import { type RevokedClientKeys } from '../webvh/revokeClient.js'; import { type CascadeCollections, type SealableEncryptionDescriptorStore, type UserKey, type UserKeyAdoptedHook, type UserKeyCascadeResult } from '../keys/index.js'; import type { UnlockLogStore } from '../unlock/standingWebvh.js'; import { type ClientAnnexWriteStore } from './log.js'; /** * What the ceremony's generation stage did: the ids of every doomed * delegation actually revoked (the POST landed, or was-client answered the * genuine `AlreadyRevokedError`; a doomed delegation expired beyond the * clock-skew margin is skipped and not listed, and since every doomed * delegation's proof key is this credential's own ladder VM, which stage 1 * just reinstalled, a server refusal is never read as signer death), and * whether the stage wrote a replacement -- `false` with no `skipped` * reason means nothing was owed, the standing delegation being one a * surviving sibling credential's ladder signed. A `skipped` reason names * the stage that could not run -- * `no-pointer` (the account points at no generation: nothing to revoke or * replace), `log-unreadable` (the pointed generation's `did.jsonl` is gone; * the delegation bytes are unrecoverable, decision 0006's honest limit), * `rung-uncommitted` (this credential cannot write the generation's log; the * account lands delegation-less until a fresh-generation heal), * `already-removed` (the whole ceremony completed on an earlier run; nothing * here can still invoke) -- and a skip never fails the ceremony. A doomed * delegation that fails to revoke for any other reason is NOT a skip: the * stage throws instead, so the ceremony halts before the removal entry * rather than declaring the resurrection window closed while that delegation * still stands. */ export interface GenerationDelegationRetirement { revoked: string[]; replaced: boolean; skipped?: 'no-pointer' | 'log-unreadable' | 'rung-uncommitted' | 'already-removed'; } /** * What a completed last-client forget reports: whether the reinstall half of * the strike-and-reinstall pair ran on this call (`false` on a resumed run * that owed no rotation, so neither entry was published), whether the * roster's wrap for the * forgotten client was retired on this run, the per-collection fan-out * result, the generation stage's report, the document as the removal entry * left it, and -- when the account has a roster -- the rotated key with the * roster descriptor it was read from. */ export interface LastEnrolledClientForgetResult { reinstalled: boolean; rotated: boolean; collections: UserKeyCascadeResult; generation: GenerationDelegationRetirement; did: string; document: object; userKey?: UserKey; rosterDescriptor?: CollectionEncryption; } /** * Forgets the account's LAST enrolled client -- this browser's own * -- transitioning the account to the client-less, ladder-anchored state. * See the module doc for the stage order and the torn-state map. The caller * runs the local wipe only after this resolves. An account with another * enrolled client refuses: that forget is the ordinary ceremony * (`forgetEnrolledClient`), reached first, whose * `LastEnrolledClientForgetError` is what routes callers here. * * @param options {object} * @param options.logStore {UnlockLogStore} the credential's delegated * `did.jsonl` bridge store; also serves the ceremony's public reads. Every * account-log read the ceremony makes -- the opening read, and the strike, * reinstall, and removal entries' own reads inside their conflict-retry * loops -- is checked against the chain-head pin its store carries, so a * served truncated prefix is refused (`ResourceLogContinuityError`, * `rollback`) before any roster append or log publish, and each entry * advances the pin to the head it publishes * @param options.clientLogStore {UnlockLogStore} the account-log store * invoked under the STILL-STANDING enrolled client's root authority (an * app's `wasWebvhIdStore` satisfies the narrower shape). The * strike-and-reinstall pair publishes through it, because the bridge * `logStore` is often signed by the ladder VM the strike removes and the * reinstall would then be refused under the current-key-set rule. Both * entries stay ladder-signed; only the HTTP invocation differs. It also * carries the removal entry's pre-entry `did:web` projection PUT (stage 6), * the one write the bridge store cannot make. Required: a call without it * throws a `TypeError` before any read * @param options.ladderSeed {Uint8Array} the login credential's ladder seed * @param options.forgottenClient {RevokedClientKeys} this client's public * halves; an `updateKeyMultibase` the log does not authorize (stale, or the * staged key) is re-derived from the log * @param options.forgottenKeyAgreementKeyMultibase {string} this client's * identity key-agreement key (the X25519 twin), naming its roster wrap * @param [options.knownLatentHashes] {string[]} standing latent commitments * the caller vouches for (the recovery registry's update-key hashes), * excluded from the staged-hash attribution * @param options.expectedDid {string} the account DID from the caller's * stored account pointer * @param options.rosterStore {SealableEncryptionDescriptorStore} the * log-governed `key-map/user-key.jsonl` roster store whose appends are * SIGNED BY THE LADDER VM while its HTTP requests invoke under the * still-standing client. Its controller view is the ceremony's to anchor, * not the app's: the orchestrator sets the minimum controller version at * the pre-transition head for the opening read and at the post-reinstall * head for the rotation (the inventory-changing anchor the ceremony-tail * license admits), so a store wired over a cached pre-transition view * still anchors the one ladder-signed append past the reinstall entry. * Required to be anchorable: a store without `setMinimumControllerVersion` * is refused with a `TypeError` before any read, since the pair would * otherwise publish two entries per attempt ahead of a rotation the * license refuses every time * @param options.credentialKeyAgreementKey {IKeyAgreementKey} the standing * credential's key-agreement key -- the recipient whose wrap survives the * rotation, reading the fresh key back and unwrapping the generations for * the fan-out * @param [options.userKey] {UserKey} this client's cached user key * @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 * @param options.collections {CascadeCollections} the fan-out's work * @param options.annex {object} the generation stage's reach: * @param options.annex.storeFor {Function} * `({ spaceId, generationId }) => ClientAnnexWriteStore` -- the pointed * generation's log store, reading and writing under the still-standing * client's authority * @param options.annex.revoke {Function} `(delegation) => Promise` * -- POSTs a revocation (was-client's `WasClient#revoke`, bound by the * caller; the invocation signs as the still-standing client) * @param options.annex.wasServerUrl {string} the ACCOUNT Space's storage * server (the fresh generation delegation's target host) * @param options.annex.accountSpaceId {string} the ACCOUNT Space's id (the * fresh delegation's target subtree) * @param options.onBeforeRemoval {Function} * `({ did, doc, log }) => Promise` -- the record re-bind seam: runs * immediately before the removal entry, with the post-reinstall published * state. The caller re-signs the login credential's bridge and * `delegatedClients` sibling with the ladder VM and re-seals its unlock * record here -- the only unlock record this ceremony writes. Must be * idempotent (a resumed run invokes it again). Required: a call without it * throws a `TypeError` before any read * @param [options.now] {number} epoch milliseconds, for tests * @returns {Promise} */ export declare function forgetLastEnrolledClient({ logStore, clientLogStore, ladderSeed, forgottenClient, forgottenKeyAgreementKeyMultibase, knownLatentHashes, expectedDid, rosterStore, credentialKeyAgreementKey, userKey, pinnedEpochId, onUserKeyAdopted, collections, annex, onBeforeRemoval, now }: { logStore: UnlockLogStore; clientLogStore: UnlockLogStore; ladderSeed: Uint8Array; forgottenClient: RevokedClientKeys; forgottenKeyAgreementKeyMultibase: string; knownLatentHashes?: string[]; expectedDid: string; rosterStore: SealableEncryptionDescriptorStore; credentialKeyAgreementKey: IKeyAgreementKey; userKey?: UserKey; pinnedEpochId?: string | null; onUserKeyAdopted?: UserKeyAdoptedHook; collections: CascadeCollections; annex: { storeFor: (options: { spaceId: string; generationId: string; }) => ClientAnnexWriteStore; revoke: (delegation: IDelegatedZcap) => Promise; wasServerUrl: string; accountSpaceId: string; }; onBeforeRemoval: (published: { did: string; doc: object; log: DIDLog; }) => Promise; now?: number; }): Promise; /** * THE LAST-CLIENT REMOVAL ENTRY (stage 6, the transition's own removal): the * plain forget's removal shape (`clientForgetEntryOnce`) with this ceremony's * removability invariant injected instead of the plain forget's last-client * refusal -- the forgotten client IS the last enrolled client, and the * account stays invocable because the ladder VM the reinstall entry published * remains in the document. Run only from {@link forgetLastEnrolledClient}, * which sequences the strike-and-reinstall pair, the rotation, and the * revocations before it. * * @param options {object} see `forgetWebvhClient` in `ladderAnchored.ts` * @returns {Promise<{ did: string, doc: DIDDoc, log: DIDLog }>} */ export declare function forgetLastWebvhClient(options: { store: UnlockLogStore; projectionStore?: Pick; ladderSeed: Uint8Array; forgottenClient: RevokedClientKeys; knownLatentHashes?: string[]; expectedDid?: string; }): Promise<{ did: string; doc: DIDDoc; log: DIDLog; }>; //# sourceMappingURL=forgetLast.d.ts.map