/*! * Copyright (c) 2026 Interop Alliance. All rights reserved. */ /** * The client enrollment ceremony: connecting a second wallet client (a fresh * browser profile, another app install) to an existing account without any * secret ever leaving either side. The new client mints its whole key set * locally -- client seed, did:webvh update-key seeds -- and only PUBLIC halves * travel, as a compact "connect code" carried point-to-point (pasted between * two browsers in the room today; the same payload renders as a QR for a * camera-holding wallet). Nothing travels back over the channel: the account * pointer comes out of the keyring (the enrollee holds the unlock secret), and * the user key comes back through the wrap-set roster. * * Push, not pull, in the recovery-anchor order (decryption material before * authorization): the enrolling client wraps the user key to the new client's * key-agreement key in `key-map/user-key.jsonl` FIRST, then writes the two * did:webvh log entries (commit, then add-VMs-and-update-key). No * authorized-but-blind window exists at any point, and both tear points * resume by re-running the ceremony with the same code -- a tear after the * roster write leaves an orphan wrap (invisible to authorization), a tear * between the log entries is detected from the published commitments. * * The new client's first roster read happens post-enrollment, signed with its * `#` key -- the server authorizes it under the * current-key-set rule the moment the add entry publishes. Persisting the key * set under the app's own unlock layer is the caller's job: this module hands * back the user key and the roster epoch to pin, and stops there. */ import type { IKeyAgreementKey } from '@interop/data-integrity-core'; import type { ServiceDescription } from '@interop/was-client'; import type { EncryptionDescriptorStore } from '@interop/was-client/edv/core'; import type { AccountLogSigner } from '../webvh/accountEntry.js'; import type { ClientWebvhUpdateKeys, WebvhEnrollmentKeys, WebvhIdStore } from '../webvh/didWebvh.js'; import { type ResourceLogPinStore } from '@interop/vh-resource-log'; import type { UserKey } from '../keys/userKey.js'; import type { AccountPointer } from '../keyring/recordEnvelope.js'; /** * The public halves a connect code carries -- the same four multibases * `enrollWebvhClient` publishes into the document, minted by the enrollee and * verified point-to-point by the person running the ceremony. */ export type EnrollmentRequest = WebvhEnrollmentKeys; /** * Thrown by the enrollee's completion step while the enrolling side has not * (yet) published the add entry -- the "not approved yet" state, retried by * completing again once the other client finishes. */ export declare class EnrollmentPendingError extends Error { constructor(message?: string); } /** * Encodes an enrollment request as a connect code. * * @param options {object} * @param options.request {EnrollmentRequest} * @returns {string} */ export declare function encodeEnrollmentRequest({ request }: { request: EnrollmentRequest; }): string; /** * Parses and validates a connect code: the prefix, the payload version, and * each key decoded to its bytes (Ed25519 multibase for the signing and update * keys, X25519 for the key-agreement key), plus the canonicality of the * key-agreement key ({@link assertCanonicalEnrollmentKeys}), so the refusal * reaches the approver's consent screen rather than the ceremony. Throws on * anything malformed -- a * code is typed or scanned, so a clear refusal beats a half-parsed ceremony, * and the approving client signs these keys into an append-only log where a * corrupted one would be published permanently. * * @param options {object} * @param options.code {string} the pasted/scanned connect code * @returns {EnrollmentRequest} */ export declare function parseEnrollmentRequest({ code }: { code: string; }): EnrollmentRequest; /** * Refuses a connect code whose key-agreement key is not the canonical X25519 * twin of its signing key. * * The document publishes a client's key-agreement method under the controller * marker `did:key:` -- a claim that the key belongs to * that signing key -- and every reader (the client listing, the revocation * removal, the roster's recipient resolver) trusts it. This check is what * makes the claim true: without it an enrollee could supply a key-agreement * key nobody else can pair with its signing key, and publishing it under the * marker would state something the account cannot back. * * It runs here so the refusal reaches the approver's consent screen before * anything is published; the write sites enforce the same rule as a backstop, * through `markedVerificationMethodPair`. * * @param options {object} * @param options.request {EnrollmentRequest} * @returns {void} */ export declare function assertCanonicalEnrollmentKeys({ request }: { request: EnrollmentRequest; }): void; /** * The did:key a connect code's signing key derives -- shown on BOTH screens * so the person running the ceremony can compare them before approving (the * point-to-point verification the wrap and the document VM then inherit). * * @param options {object} * @param options.request {EnrollmentRequest} * @returns {string} */ export declare function enrollmentClientDid({ request }: { request: EnrollmentRequest; }): string; /** * The kid of the enrollee's roster entry: its key-agreement key's id exactly * as `agentsFromSeed` will derive it at the enrollee's own logins * (`did:key:#`), so the wrap minted here is the * one its first roster read looks for. * * @param options {object} * @param options.request {EnrollmentRequest} * @returns {string} */ export declare function enrollmentRecipientKid({ request }: { request: EnrollmentRequest; }): string; /** * ENROLLEE, step one: mints the new client's whole key set locally and * returns it alongside the connect code to display. The caller holds the * seeds in memory until the completion step persists them -- nothing is * written anywhere before the ceremony succeeds, so abandoning it leaks at * most an orphan wrap on the enrolling side. * * @returns {Promise} the in-memory key set, the code, and the * client did:key to display beside it for comparison */ export declare function mintEnrollmentRequest(): Promise<{ clientSeed: Uint8Array; webvhUpdateKeys: ClientWebvhUpdateKeys; code: string; clientDid: string; }>; /** * THE APPROVING SIDE: the whole approval -- the user key wrapped into the * roster (escrow: every epoch, so the new client reads pre-enrollment * history) and the two log entries. Idempotent at every stage, so * re-approving the same code after any tear converges. A request whose * key-agreement key is not its signing key's canonical twin is refused before * anything is written ({@link assertCanonicalEnrollmentKeys}). * * The escrow is placed by signer kind (`decisions/0018`). A CLIENT signer * keeps the push order, the escrow first: an enrolled client's roster append * needs no license, so it may precede the entries, and no enrolled client is * ever authorized but blind. Quorum-of-one there: any single enrolled * client's own update key signs both entries. * * A LADDER signer runs commit, add, then escrow, because a ladder-signed * append is licensed only at an inventory-changing version its own ladder * signed, which the add entry is what mints. The one-request window between * the add entry and the append is the ladder branch's stated cost: a client * the add entry published holds `assertionMethod` and its own update key * while holding no wrap. It is bounded three ways -- one request wide on the * happy path, mended by a re-run with the same connect code and by the * escrow-direction convergence of any later ladder-branch ceremony (the * document carries the client's key verbatim), and visible as a row in the * connected-wallets listing, where a disconnect removes it. * * On that branch the escrow must ANCHOR at the add entry's version, and the * ceremony does not leave that to the roster store's own controller wiring: * a store resolving its controller view through a document cached before * the entries (an app's session-verified log) would anchor the append at the * pre-add head, and the license would refuse it after the ceremony's pivot. * So, on a sealable roster store, the view built from the add entry's own * post-add log is set as the store's minimum controller version before the * escrow runs -- the same guarantee `revokeAccountClient` gives its rotation * from its post-edit log. A fresher resolved view still wins. * * @param options {object} * @param options.request {EnrollmentRequest} the parsed connect code * @param options.signer {AccountLogSigner} who signs the two entries: the * approving client's own did:webvh update-key seeds, or the acting * credential's ladder seed * @param options.clientKeyAgreementKey {IKeyAgreementKey} the approving * party's own key-agreement key, unwrapping each epoch for re-wrapping * @param options.userKeyRosterStore {EncryptionDescriptorStore} the account's * `key-map/user-key.jsonl` descriptor store * @param options.idStore {WebvhIdStore} the account's `id` collection * @returns {Promise} the account's did:webvh, plus the enrollee's * own identity as this call already knows it: its did:key and its signing-key * multibase (the key a label or a listing row is filed under), so the caller * needs no second parse of the connect code */ export declare function approveEnrollment({ request, signer, clientKeyAgreementKey, userKeyRosterStore, idStore }: { request: EnrollmentRequest; signer: AccountLogSigner; clientKeyAgreementKey: IKeyAgreementKey; userKeyRosterStore: EncryptionDescriptorStore; idStore: WebvhIdStore; }): Promise<{ did: string; clientDid: string; signingKeyMultibase: string; }>; /** * ENROLLEE, step two (after the other client approves): verifies the * enrollment from the world-readable log and performs this client's first * roster read -- signed with its just-published `#` * key -- to obtain the user key. * * The portable core of the completion step: the caller supplies the account * pointer (from its own keyring lookup) and persists what comes back -- the * user key, the roster epoch to pin, and the key set itself -- under its own * unlock layer. After that, an ordinary unlock finds an enrolled client. * * Throws `EnrollmentPendingError` while the add entry is not published yet * (complete again once the other client finishes); any integrity failure * (a log that resolves to a different DID than the account pointer names, a * missing roster wrap) throws its own error. * * @param options {object} * @param options.clientSeed {Uint8Array} from `mintEnrollmentRequest` * @param options.webvhUpdateKeys {ClientWebvhUpdateKeys} from * `mintEnrollmentRequest` * @param options.pointer {AccountPointer} the account pointer the enrollee's * keyring lookup recovered * @param [options.accountLogPinStore] {ResourceLogPinStore} this client's * chain-head pin for the account log. A freshly enrolling client normally * has none (this read is its first contact), which is exactly the pin's * trust-on-first-use establishment * @param [options.serviceDescription] {ServiceDescription} the server's * service description, handed to every client built here so none * discovers on its own * @returns {Promise<{ userKey: UserKey, latestEpochId: string }>} */ export declare function completeEnrollmentCore({ clientSeed, webvhUpdateKeys, pointer, accountLogPinStore, serviceDescription }: { clientSeed: Uint8Array; webvhUpdateKeys: ClientWebvhUpdateKeys; pointer: AccountPointer; accountLogPinStore?: ResourceLogPinStore; serviceDescription?: ServiceDescription; }): Promise<{ userKey: UserKey; latestEpochId: string; }>; //# sourceMappingURL=enrollment.d.ts.map