/*! * Copyright (c) 2026 Interop Alliance. All rights reserved. */ /** * The user key wrap set: the roster governed by `key-map/user-key.jsonl`. Its state * is a `CollectionEncryption` descriptor verbatim, whose current epoch IS the * current user key -- the epoch id is the user key's did:key and the wrapped * secret is the user key's raw 32-byte key, wrapped to each enrolled client's * key-agreement key. The roster is the delivery channel for user key rotation: * each client keeps the user key in its own local state under the unlock layer, * and the roster's epoch stamp marks a cached copy stale. * * Everything mutates through was-client's descriptor-store seam -- since the * roster became log-governed, the log-backed adapter * (`logGovernedDescriptorStore`): reads resolve to the roster log's VERIFIED * head state, writes append signed entries. No descriptor logic is * reimplemented here. * * A resource-hosted descriptor gets NONE of the server-side epoch invariants a * Collection Description enforces (append-only epochs, monotone * `currentEpoch`), so the client-side compensations are load-bearing alone * against a tampering host: * * - **The resource log** -- the roster is governed by a hash-linked log whose * every entry is signed by an enrolled client's key, anchored in the * locally verified did:webvh document, and continuity-checked against the * client's chain-head pin (the `resourceLog` module). A fabricated roster * fails entry-proof verification (`ResourceLogIntegrityError`); a rolled * back, forked, or format-switched log fails continuity * (`ResourceLogContinuityError`). This is the successor of the retired * detached `epochsSig`: the entry proof covers the whole configuration, on * every read instead of only the adopt path. (The `epochsMac` * epoch-configuration MAC that used to sit beneath it is retired stack-wide: * on a log-governed resource its coverage was a strict subset of chain * verification.) * - **The epoch pin** -- the latest-seen roster epoch is pinned locally by the * consuming app (beside the account-pointer pin); a served * roster that rolls back behind the pin is refused * (`UserKeyRosterContinuityError`) rather than followed, even where the * chain-head pin was lost with a reinstalled client. * - **The roster delivers, never sources** -- the recipient-key source of * record is the locally verified did:webvh document (one `keyAgreement` * verification method per enrolled client). When an epoch rotates, each * remaining recipient's key is resolved from that document * (`userKeyRosterRecipientResolver`); a roster entry with no matching document * verification method is dropped and never receives a wrap, so a * server-injected entry sits ignored. Wraps are minted only by enrolled * clients, against log-verified keys. */ import type { IKeyAgreementKey } from '@interop/data-integrity-core'; import type { CollectionEncryption, CollectionEncryptionEpoch } from '@interop/was-client'; import { type EncryptionDescriptorStore, type RecipientPublicKey } from '@interop/was-client/edv/core'; import { type ResourceLogSigner } from '@interop/vh-resource-log'; import type { KeyAgreementDocument } from '../resourceLog/document.js'; import { type ICapabilityAgent } from '../webvh/zcap.js'; import type { UserKey } from './userKey.js'; /** * Thrown when a served roster fails its client-side consistency checks: a * descriptor whose `currentEpoch` names no epoch in its own list. The server * (or whoever can write to it) has produced a configuration no enrolled * client authenticated. */ export declare class UserKeyRosterIntegrityError extends Error { constructor(message: string); } /** * Resolves a descriptor's current epoch, refusing a descriptor whose * `currentEpoch` names no epoch in its own list -- the shape no enrolled * client authenticated. Every site that resolves a current epoch goes * through here, so the refusal has one implementation and one class. * * @param options {object} * @param options.descriptor {CollectionEncryption} * @param options.label {string} names the descriptor in the refusal, e.g. * 'The user key roster' or 'The collection descriptor' * @returns {CollectionEncryptionEpoch} the current epoch * @throws {UserKeyRosterIntegrityError} */ export declare function currentEpochOf({ descriptor, label }: { descriptor: CollectionEncryption; label: string; }): CollectionEncryptionEpoch; /** * Whether a roster's CURRENT epoch wraps to the named recipient -- the probe * the enrollment wrap asks before adding, and the recipient-retiring cascade * tail asks before rotating (a recipient with no current-epoch wrap has * nothing to rotate off). Resolves the current epoch through * {@link currentEpochOf}, so a roster whose `currentEpoch` names no epoch in * its own list is refused rather than read as "not wrapped". * * @param options {object} * @param options.descriptor {CollectionEncryption} the roster descriptor * @param options.recipientId {string} the roster kid * @returns {boolean} * @throws {UserKeyRosterIntegrityError} */ export declare function rosterWrapsRecipient({ descriptor, recipientId }: { descriptor: CollectionEncryption; recipientId: string; }): boolean; /** * Thrown when a served roster conflicts with the locally pinned latest-seen * epoch -- the epochs list no longer contains the pinned epoch, or * `currentEpoch` precedes it in the (append-only) list. A rollback/replay of * an older consistent configuration; refused rather than followed. */ export declare class UserKeyRosterContinuityError extends Error { pinnedEpochId: string; constructor({ pinnedEpochId }: { pinnedEpochId: string; }); } /** * Thrown when this client holds no usable wrap in the roster's current epoch * (no recipient entry for its key-agreement key, or the entry fails to * unwrap). The client cannot obtain the current user key -- it may have been * rotated off the roster. */ export declare class UserKeyRosterUnwrapError extends Error { constructor(message: string); } /** * The roster's log signer: signs each roster log append with this client's * own Ed25519 signing key, named by its public multibase -- exactly the * string enrolled as this client's verification method in the did:webvh * document, so a reader resolves the entry proof against the document rather * than anything the roster (or the server) supplies. Successor of the retired * `epochsSig` signer under the log design. * * The name says which log it was built for first, not which logs it serves: * the same signer signs the per-collection encryption descriptor logs * (`collectionDescriptorLogStore`), which take the same `ResourceLogSigner` * seam and resolve their proofs against the same document. * * @param options {object} * @param options.keyAgent {ICapabilityAgent} this client's signing key * agent (the `keyAgent` of `agentsFromSeed`) * @returns {ResourceLogSigner} the signer for the log-governed store's * appends */ export declare function userKeyRosterLogSigner({ keyAgent }: { keyAgent: ICapabilityAgent; }): ResourceLogSigner; /** * Builds the recipient resolver for roster rotations, backed by a locally * verified did:webvh document -- the enforcement point for "the roster * delivers, never sources". Given a remaining recipient's `kid`, it answers * with that recipient's public key ONLY when the document carries a matching * `keyAgreement` verification method (matched on the public-key multibase, so * a did:key-form kid matches its `#` VM); otherwise it * resolves `null` -- the was-client skip contract -- so the entry is dropped * from the fresh epoch and never receives a wrap. * * The match is over EVERY key-agreement method the document publishes * ({@link resolvedKeyAgreementMethods}), deliberately including the unmarked * ones. That is where this predicate parts company with * `markedKeyAgreementMethods`, which hard-requires a client's controller * marker: a recovery code's or standing unlock credential's key-agreement * method is published unmarked by design, so that client listings and * revocations never match it, and it must keep its user key wrap through every * rotation. Filtering by the marker here would drop every such credential from * each rotated epoch. * * Two branches back an entry, and a method's `type` selects exactly one. A * method that is not a `MultikeyCommitment` and carries the key verbatim * (`publicKeyMultibase`) matches on the multibase (so a did:key-form kid * matches its `#` VM). A `MultikeyCommitment` method's * hash commitment (`publicKeyCommitment` -- a low-entropy-derived standing * credential, whose key material the document withholds) matches when it * commits to the roster entry's own key: the roster carries the real key, the * document vouches for it, and a server-injected entry can neither meet a * standing commitment nor add one. The type split means a hybrid method * carrying both properties backs at most one recipient -- the flavor its * `type` declares -- never two. * * @param options {object} * @param options.document {KeyAgreementDocument} the locally verified * did:webvh document (never a server-supplied roster field) * @returns {function} a `resolveRecipientKey` for `removeRecipient` */ export declare function userKeyRosterRecipientResolver({ document }: { document: KeyAgreementDocument; }): (kid: string) => Promise; /** * Ensures the roster exists, create-if-absent: an absent roster is initialized * with the account's existing user key installed as the first epoch, wrapped to * this client's key-agreement key; an existing roster is returned as-is * (authentication is the read path's job, and provisioning must never clobber * an established roster). Idempotent -- losing the guarded-create race to a * concurrent first init converges on the winner's roster. * * @param options {object} * @param options.store {EncryptionDescriptorStore} the roster's descriptor * store * @param options.userKey {UserKey} the account's user key * @param options.clientKeyAgreementKey {IKeyAgreementKey} this client's own * (identity) key-agreement key -- the roster recipient * @returns {Promise} the roster descriptor */ export declare function ensureUserKeyRoster({ store, userKey, clientKeyAgreementKey }: { store: EncryptionDescriptorStore; userKey: UserKey; clientKeyAgreementKey: IKeyAgreementKey; }): Promise; /** * What a descriptor store's `read()` resolves to when the descriptor exists: * the descriptor and the compare-and-swap validator it was served under. */ export type DescriptorStoreRead = NonNullable>>; /** * Wraps the user key to a client being enrolled -- the roster half of the * enrollment ceremony, and deliberately its FIRST write (decryption material * before authorization, the push order): the wrap lands before the did:webvh * log entries, so no enrolled client is ever authorized but blind, and a tear * right after this write leaves only an orphan wrap -- invisible to * authorization, harmless, resumed by re-running the ceremony. * * Escrow semantics ride on was-client's `addRecipient`: the new client * receives EVERY epoch's key, current and prior, so it decrypts * pre-enrollment history. The recipient key arrives over the point-to-point * enrollment channel and is verified there by the enrolling client (the * document VM it writes next comes from the same exchange) -- never sourced * from the roster. Idempotent: a wrap already standing in the current epoch * is returned as-is. * * @param options {object} * @param options.store {EncryptionDescriptorStore} the roster's descriptor * store * @param options.recipient {RecipientPublicKey} the enrollee's public * key-agreement key; `id` is the kid its own roster reads will look for * @param options.ownerKeyAgreementKey {IKeyAgreementKey} the enrolling * client's own (identity) key-agreement key, unwrapping each epoch for * re-wrapping * @returns {Promise} the refreshed roster descriptor * @throws {UserKeyRosterIntegrityError} the descriptor's `currentEpoch` * names no epoch in its own list */ export declare function addUserKeyRosterRecipient({ store, recipient, ownerKeyAgreementKey }: { store: EncryptionDescriptorStore; recipient: RecipientPublicKey; ownerKeyAgreementKey: IKeyAgreementKey; }): Promise; /** * Rotates the user key roster off one recipient -- the roster half of revoking * an enrolled wallet client or a recovery code. A thin, deliberate composition * of was-client's `removeRecipient` with the two roster-specific choices * spelled once: the remaining recipients are resolved from the locally verified * did:webvh document ("the roster delivers, never sources" -- an entry with no * matching `keyAgreement` verification method is dropped and never receives a * wrap), and the pull axis is a no-op, because for a roster recipient the pull * axis IS the document edit the caller performed first -- under the * current-key-set rule the removed party's server-side access died the moment * its verification method left the document. * * @param options {object} * @param options.store {EncryptionDescriptorStore} the roster's descriptor * store * @param options.document {KeyAgreementDocument} the locally verified * did:webvh document, AFTER the removal edit * @param options.retireRecipientId {string} the removed recipient's roster * kid * @param [options.current] {DescriptorStoreRead} a read the caller has just * performed on this same store instance, seeding the rotation's * compare-and-swap so the roster is not acquired a second time; a lost * compare-and-swap re-reads the store as usual * @returns {Promise} the rotated roster descriptor */ export declare function rotateUserKeyRoster({ store, document, retireRecipientId, current }: { store: EncryptionDescriptorStore; document: KeyAgreementDocument; retireRecipientId: string; current?: DescriptorStoreRead; }): Promise; /** * Rotates the roster off one or more recipients while escrowing incoming ones, * in ONE descriptor write -- the transient-recovery continuation's mandatory * rotation. The shape is forced by the ceremony-tail license: on a client-less * account the only roster signer is the ladder VM, whose append is one-shot at * the continuation's inventory-changing document entry, so the retiring wrap * (the spent code's), the incoming recipients (the fresh credential's standing * key and the replacement code's), and the fresh-epoch mint must all land in a * single append. Composition of was-client's `replaceRecipient` with the same * two roster choices {@link rotateUserKeyRoster} spells: recipients resolved * from the locally verified document, and a no-op pull axis (the document edit * the caller performed first IS the pull axis). * * The incoming recipients' keys are supplied by the caller (it derived them); * the document must already back them -- the continuation's own entry * published their inventory -- or the next rotation would drop them. * * @param options {object} * @param options.store {EncryptionDescriptorStore} the roster's descriptor * store * @param options.document {KeyAgreementDocument} the locally verified * did:webvh document, AFTER the continuation's entry * @param options.retireRecipientIds {string[]} the retiring roster kids: the * spent code's, and every other pre-recovery credential's, since the * continuation's entry retires them all in one go * ({@link rosterRecipientsToRetire} names them). The document-backed * resolver is the backstop rather than the mechanism -- a recipient the * post-entry document no longer keys is dropped from the fresh epoch * whether or not it is named here * @param options.recipients {RecipientPublicKey[]} the incoming readers' * public key-agreement keys; each `id` is the kid its own roster reads will * look for * @param options.ownerKeyAgreementKey {IKeyAgreementKey} a key-agreement key * holding a wrap in every epoch (the spent code's qualifies), unwrapping * each epoch for the escrow * @param [options.current] {DescriptorStoreRead} a read the caller has just * performed on this same store instance, seeding the compare-and-swap (see * {@link rotateUserKeyRoster}) * @returns {Promise} the rotated roster descriptor */ export declare function replaceUserKeyRosterRecipients({ store, document, retireRecipientIds, recipients, ownerKeyAgreementKey, current }: { store: EncryptionDescriptorStore; document: KeyAgreementDocument; retireRecipientIds: string[]; recipients: RecipientPublicKey[]; ownerKeyAgreementKey: IKeyAgreementKey; current?: DescriptorStoreRead; }): Promise; /** * The current epoch's recipient kids minus the ones to keep -- what a * recovery continuation hands `replaceUserKeyRosterRecipients` as * `retireRecipientIds` once its entry has retired several credentials at * once. Pure and synchronous: it reads the descriptor the caller already * holds and decides nothing about who deserves a wrap. * * The keep set is the caller's: the fresh credential's kid, the replacement * code's, and every surviving enrolled client's * ({@link rosterRecipientKid} over the post-entry document). Naming a * retiring kid is belt and braces -- the document-backed resolver * ({@link userKeyRosterRecipientResolver}) already drops any recipient the * post-entry document no longer keys -- but it keeps the retirement explicit * in the one append the ceremony-tail license admits. * * @param options {object} * @param options.descriptor {CollectionEncryption} the roster descriptor * the rotation is about to replace * @param options.keepRecipientIds {string[]} the kids that stay * @returns {string[]} the current epoch's other kids, in epoch order */ export declare function rosterRecipientsToRetire({ descriptor, keepRecipientIds }: { descriptor: CollectionEncryption; keepRecipientIds: string[]; }): string[]; /** * The enrolled clients the account document keys, as roster recipients. A * client's `keyAgreement` twin carries the controller marker * `did:key:` (`clientKeyAgreementController` is the one * write-side builder), so the marker names the signing half and the method * carries the key-agreement half -- together, exactly the pair * {@link rosterRecipientKid} takes. That is what makes the escrow direction of * the convergence possible from the document alone. * * A standing unlock credential is deliberately not here, and not because it is * unwanted. Its roster kid names its standing client's SIGNING key, which the * document never publishes: the document carries the credential's * key-agreement key (or a commitment to it) and nothing else, so no reader can * rebuild the kid its own roster reads look for. A credential's missing wrap * is therefore mended by the ceremony that holds the credential, never here. * * @param options {object} * @param options.document {KeyAgreementDocument} the locally verified * did:webvh document * @returns {RecipientPublicKey[]} in document order */ export declare function enrolledClientRosterRecipients({ document }: { document: KeyAgreementDocument; }): RecipientPublicKey[]; /** * Converges the roster onto the account document: the standing detector for a * revocation cascade torn between its two halves. The cascade edits the * document first and rotates the roster second, so a client that crashes in * between leaves a roster that keeps wrapping the CURRENT user key to a * recipient the document no longer keys -- durable, silent, and permanent, * since the revoked client's document edit will never be re-run. * * The detection is pure durable state: a current-epoch recipient the * document-backed resolver cannot answer for is exactly a recipient the * document no longer keys, so a healthy account reads the descriptor and * writes nothing. When any such recipient is found the roster is rotated away * from ALL of them at once, because the resolver drops every unbacked entry * from the fresh epoch, not just the one named as retiring. * * The convergence runs in TWO directions, in one append. The retire direction * is above. The escrow direction is its mirror: an enrolled client the * document keys that holds no wrap in the current epoch is escrowed into every * epoch ({@link enrolledClientRosterRecipients} names the candidates). That is * the mender for a ceremony torn between the entry that published a client and * the append that was to wrap the user key to it -- the one-request window a * ladder-signed enrollment approval leaves, where the ladder's append is * licensed only as the tail of its own entry and cannot precede it. A standing * unlock credential is not a candidate: its roster kid names a signing key the * document does not publish (see the helper), so its missing wrap is mended by * the ceremony holding the credential. * * Either direction, and both together, cost exactly ONE descriptor write. A * convergence needing both rides `replaceRecipient`; a pure escrow rides * {@link escrowRosterRecipients}, which stages every missing recipient's * wraps through was-client's `addRecipient` and writes once, so the user key * is not rotated for a missing wrap and a ladder-signed convergence never * needs a second append it has no license for. A healthy roster writes * nothing at all. The escrow direction needs a key that unwraps every epoch, * so it runs only for a caller that supplies `ownerKeyAgreementKey`; without * one the retire direction runs alone. * * Rotating onto nobody is refused: a current epoch in which NO recipient is * backed by the document is a mismatched pair (a stale document, the wrong * account), not a cascade to finish, and completing it would lock every * client out of the account. * * The fresh user key itself is not returned: the caller adopts it the * ordinary way, by re-reading the roster ({@link readUserKeyRoster}) once * `rotated` says there is something to adopt, and then runs the collection * fan-out. * * @param options {object} * @param options.store {EncryptionDescriptorStore} the roster's descriptor * store * @param options.document {KeyAgreementDocument} the locally verified * did:webvh document -- the recipient source of record * @param [options.descriptor] {CollectionEncryption} a descriptor the caller * has just read (a login-time roster read), to save a re-read; omitted, the * roster is read fresh * @param [options.etag] {string} the validator that same read was served * under, when it was a read on this same store instance moments ago; with * it, the write this call may make is seeded from that read rather than * re-acquiring the roster * @param [options.ownerKeyAgreementKey] {IKeyAgreementKey} a key-agreement * key holding a wrap in every epoch, unwrapping each one for the escrow * direction; omitted, only the retire direction runs * @returns {Promise} whether the roster rotated on this call, the * stale recipient kids found, the kids escrowed, and the roster descriptor * as it now stands (`null` when the account has no roster yet) */ export declare function convergeUserKeyRosterToDocument({ store, document, descriptor, etag, ownerKeyAgreementKey }: { store: EncryptionDescriptorStore; document: KeyAgreementDocument; descriptor?: CollectionEncryption; etag?: string; ownerKeyAgreementKey?: IKeyAgreementKey; }): Promise<{ rotated: boolean; staleRecipientIds: string[]; escrowedRecipientIds: string[]; descriptor: CollectionEncryption | null; }>; /** * What a roster read resolves to: the authenticated descriptor, the current * user key (the cached one confirmed current, or a fresh one unwrapped from a * rotated epoch -- `rotated` says which), and the epoch id the caller must pin * as the new latest-seen. */ export interface UserKeyRosterReadResult { descriptor: CollectionEncryption; userKey: UserKey; rotated: boolean; latestEpochId: string; /** * The validator the store served `descriptor` under, when this read * fetched it itself and the store offered one: what a later write on the * same store instance may seed its compare-and-swap from * (`convergeUserKeyRosterToDocument`'s `etag`). Absent on a read built on a * threaded descriptor. */ etag?: string; } /** * Reads and authenticates the roster -- the direct read at login and on epoch * mismatch. Resolves `null` when the roster does not exist yet (an account * provisioned before the roster, or provisioning still in flight); otherwise: * * 1. **Provenance** is the store's: a log-governed store resolves the read * from the roster log's verified head -- entry proofs checked against the * locally verified did:webvh document, chain-head pin enforced -- so * every epoch this read can deliver was signed onto the log by an * enrolled client. (The detached `epochsSig` this step used to verify on * the adopt path is retired; the entry proof covers every read.) * 2. **Continuity**: the served epochs must contain the pinned latest-seen * epoch, and `currentEpoch` must not precede it in the append-only list * (`UserKeyRosterContinuityError` -- the rollback/replay refusal). * 3. **Possession**: `currentEpoch === userKey.id` confirms the cached user key * current; otherwise the current epoch was rotated by another client and * this client's wrap is unwrapped with its own key-agreement key * (`UserKeyRosterUnwrapError` when it holds none). * * A rotated read returns the fresh user key, and with it the fresh signing * half: the roster wraps the key-agreement secret alone, and the Ed25519 half * derives from that secret, so a rotation delivers a complete identity. * * A caller with no cached user key at all -- a freshly enrolled client making * its first post-enrollment read -- omits `userKey` and always takes the unwrap * path; the result's `rotated` is then true (the user key was adopted from the * roster). * * A caller that just performed a verified roster operation on the same store * -- a rotation, or a read moments ago -- may thread that operation's * descriptor in as `descriptor`, skipping this read's own fetch: provenance * (step 1) is then the earlier operation's, while continuity and possession * still run here. That is a within-one-operation reuse, not a cache -- a * genuinely distinct acquisition reads the store and re-verifies as always. * * @param options {object} * @param options.store {EncryptionDescriptorStore} the roster's descriptor * store * @param [options.descriptor] {CollectionEncryption} a descriptor a verified * operation on the same store just resolved (the return of * {@link rotateUserKeyRoster}); supplied, the store is not read * @param [options.userKey] {UserKey} this client's cached user key, when it holds one * @param options.clientKeyAgreementKey {IKeyAgreementKey} this client's own * (identity) key-agreement key, unwrapping a rotated epoch * @param [options.pinnedEpochId] {string} the locally pinned latest-seen * roster epoch, when this client has seen the roster before * @returns {Promise} `null` only on an * absent roster, which a supplied `descriptor` rules out */ export declare function readUserKeyRoster(options: { store: EncryptionDescriptorStore; descriptor: CollectionEncryption; userKey?: UserKey; clientKeyAgreementKey: IKeyAgreementKey; pinnedEpochId?: string | null; }): Promise; export declare function readUserKeyRoster(options: { store: EncryptionDescriptorStore; descriptor?: CollectionEncryption; userKey?: UserKey; clientKeyAgreementKey: IKeyAgreementKey; pinnedEpochId?: string | null; }): Promise; //# sourceMappingURL=userKeyRoster.d.ts.map