/*! * Copyright (c) 2026 Interop Alliance. All rights reserved. */ /** * Recipient and key-epoch management for multi-recipient encrypted Collections: * installing the first epoch at provision time (create-if-absent), initializing * the first epoch, adding a reader (escrow -- history included), * and removing a reader (the full revoke-and-rotate procedure). Each operation * mutates a `CollectionEncryption` descriptor through the descriptor-store seam * (see `descriptorStore.ts`) -- the Collection Description's `encryption` * member for the `collection` sugar, or any explicit `store`, such as a * descriptor hosted as a plain JSON Resource -- and writes it back with a * compare-and-swap (`If-Match`), retrying on a concurrent change so two racing * recipient edits cannot clobber one another. * * The two axes stay separate and are both required to actually remove a reader: * * - **pull** -- the reader's server-side access. For a Collection this is the * zcap the server checks at request time: revoking it stops the server * serving that reader ciphertext. Immediate and total. A descriptor whose pull * axis lives elsewhere (e.g. a DID document naming the readers) supplies a * `pull` action instead of the default zcap revocation. * - **read** -- possession of an epoch key. Rotating the epoch means resources * written afterward are encrypted under a key the removed reader does not * hold. Prospective only. * * Important: Rotation protects post-rotation writes only. It never claws * back data a reader already downloaded, and a removed reader keeps every * earlier epoch's key, so any pre-rotation resource whose ciphertext it obtains * stays readable to it. {@link removeRecipient} does both halves so a caller * cannot accidentally do one; callers who truly want half can call * `space.revoke()` or nothing, respectively, themselves. */ import type { IKeyAgreementKey } from '@interop/data-integrity-core'; import type { Collection } from '../Collection.js'; import type { Space } from '../Space.js'; import type { EncryptionDescriptorStore } from './descriptorStore.js'; import type { CollectionEncryption, IDelegatedZcap } from '../types.js'; import type { RecipientPublicKey } from './epochCrypto.js'; export type { RecipientPublicKey } from './epochCrypto.js'; /** * Installs a collection's first key epoch at provision time, create-if-absent: * reads the descriptor, and only if it carries no epoch roster mints a fresh * random epoch key, wraps it to each initial recipient, and writes the roster * back through the descriptor-store seam -- the guarded create * (`If-None-Match: *`) on a store whose descriptor starts absent, or a * compare-and-swap onto an existing epoch-less descriptor (the state * `ensureSpaceAndCollection` leaves an `'edv'` collection in; that ensure stays * crypto-free, so this install is the EDV-bearing second step every encrypted * collection's provisioning runs). * * Read before mint: an adopting call (the common case once provisioning has * landed once) mints no key material at all, and a call that does mint reuses * that one epoch across its compare-and-swap retries rather than minting a * fresh key per attempt -- the retry re-reads the descriptor, so an adoption * discovered on a later attempt still adopts and the staged epoch is simply * never written. * * Non-clobbering to convergence: a descriptor that already carries epochs is * returned as-is (`installed: false`) -- in particular, on losing the * create/CAS race to a concurrent provisioner, the winner's descriptor is * re-read and returned for adoption, never overwritten. So any provisioner can * re-run this to heal a torn provisioning run, and exactly one epoch[0] ever * exists. * * @param options {object} * @param [options.collection] {Collection} the (already declared encrypted) * collection whose Description hosts the descriptor; exactly one of * `collection` / `store` * @param [options.store] {EncryptionDescriptorStore} an explicit descriptor store * @param options.recipients {RecipientPublicKey[]} the initial readers' public * key-agreement keys (each `id` is the reader's `kid`) * @param [options.blindedIndex] {boolean} install the collection's * blinded-index HMAC key alongside epoch[0], wrapped to the same initial * recipients (default `false`). Greenfield only: the key is installed at * provisioning or never -- asking for it on a descriptor that already carries * an epoch roster without one throws {@link EncryptionError}, while a * descriptor that already carries one is adopted as-is (so a torn * provisioning run still heals by re-running this with the same option). * @returns {Promise<{ descriptor: CollectionEncryption, installed: boolean }>} * the collection's epoch-bearing descriptor, and whether this call installed * its epoch[0] (`false` means an existing roster was adopted) */ export declare function ensureFirstEpoch({ collection, store, recipients, blindedIndex }: { collection?: Collection; store?: EncryptionDescriptorStore; recipients: RecipientPublicKey[]; blindedIndex?: boolean; }): Promise<{ descriptor: CollectionEncryption; installed: boolean; }>; /** * Initializes the first key epoch on a descriptor that has no epochs yet: mints * a fresh epoch key, wraps it to each initial recipient, and writes `epochs: * [epoch]` / `currentEpoch` back with a compare-and-swap. After this, resources * written by any recipient are encrypted under the epoch, and readers unwrap it * with their own key-agreement key. * * On the `collection` host the collection must already be declared encrypted * (its descriptor exists; this fills in the first epochs). On a store whose * descriptor host starts absent (e.g. `resourceDescriptorStore`, whose roster * resource does not exist before the first init), the descriptor itself is * created from scratch with a create-if-absent guard (`If-None-Match: *`), so * two racing first inits cannot clobber one another: the loser re-reads and * resolves the winner's descriptor unchanged (the caller's recipients are not * added; use `addRecipient` for that). A descriptor that already carries * epochs on the first read is refused with `ValidationError`. * * @param options {object} * @param [options.collection] {Collection} the (already encrypted) collection * whose Description hosts the descriptor; exactly one of `collection` / * `store` * @param [options.store] {EncryptionDescriptorStore} an explicit descriptor store * @param options.recipients {RecipientPublicKey[]} the initial readers' public * key-agreement keys (each `id` is the reader's `kid`) * @param [options.epoch] {{ epochId: string, secret: Uint8Array }} a * pre-minted first epoch to install instead of minting one -- for a caller * whose epoch key already exists (e.g. a per-user key being enrolled into * its wrap-set roster). The `epochId` must be the key's did:key and `secret` * its raw 32-byte private key, exactly what {@link mintEpoch} returns. * @returns {Promise} the new descriptor */ export declare function initRecipients({ collection, store, recipients, epoch: premintedEpoch }: { collection?: Collection; store?: EncryptionDescriptorStore; recipients: RecipientPublicKey[]; epoch?: { epochId: string; secret: Uint8Array; }; }): Promise; /** * Adds a reader to a multi-recipient encrypted Collection. Escrow semantics: the * new reader receives EVERY epoch's key (the current one and all prior), because * "add a reader to a collection" means it can read the Collection, history * included. No rotation happens -- **adds are cheap, removals rotate.** * * The caller must itself be a recipient of every epoch (its `owner` key unwraps * each epoch key, which is then re-wrapped to the new reader). Where the * descriptor also carries a blinded-index HMAC key, the new reader receives * that key the same way, in the same write. Written back with a * compare-and-swap, retried on a concurrent change. * * @param options {object} * @param [options.collection] {Collection} the collection whose Description * hosts the descriptor; exactly one of `collection` / `store` * @param [options.store] {EncryptionDescriptorStore} an explicit descriptor store * @param options.recipient {RecipientPublicKey} the new reader's public KAK * @param options.owner {object} the caller's own key material * @param options.owner.keyAgreementKey {IKeyAgreementKey} unwraps each epoch * key for re-wrapping to the new reader * @returns {Promise} the new descriptor */ export declare function addRecipient({ collection, store, recipient, owner }: { collection?: Collection; store?: EncryptionDescriptorStore; recipient: RecipientPublicKey; owner: { keyAgreementKey: IKeyAgreementKey; }; }): Promise; /** * Removes a reader from a multi-recipient encrypted Collection -- one * indivisible operation doing BOTH halves of a removal: * * 1. **Rotate the epoch**: mint a fresh epoch key, wrap it to each REMAINING * recipient (the current epoch's roster minus the removed reader), append it * as a new epoch, and repoint `currentEpoch`, with a compare-and-swap. * Resources written afterward are unreadable to the removed reader (the read * axis; prospective). * 2. **Pull the reader's server-side access** (the pull axis; immediate). By * default that revokes the reader's zcap(s) via `space.revoke()`, so the * server stops serving it ciphertext; a caller whose pull axis lives * elsewhere (e.g. a DID document naming the readers) supplies its own * `pull` action instead. * * The rotation runs first so it is durable before the irreversible pull: * a rotation that keeps losing the compare-and-swap throws with nothing pulled, * leaving the operation safely retryable rather than half-applied. The default * revoke step tolerates an already-revoked capability (a retry re-revokes) so * the operation converges; a custom `pull` should likewise tolerate a retry. * The rotation itself is likewise idempotent with respect * to retries: when the current epoch already excludes the departing reader * (a prior attempt's rotation landed but its pull failed transiently), no * fresh epoch is minted or appended -- the retry skips straight to the pull * step instead of accumulating a redundant epoch per attempt. * * Important: this does not re-encrypt existing resources, so the removed * reader keeps every earlier epoch's key and can still decrypt any pre-rotation * resource whose ciphertext it gets. Neither half alone removes a reader. * * Where the descriptor carries a blinded-index HMAC key, the removed reader's * wrap entry is dropped from it in the same write -- housekeeping only. That * key never rotates (blinded tokens must compare across the collection's whole * history), so the removed reader keeps the blinding key it already holds. * * @param options {object} * @param [options.collection] {Collection} the collection whose Description * hosts the descriptor; exactly one of `collection` / `store` * @param [options.store] {EncryptionDescriptorStore} an explicit descriptor store * @param [options.space] {Space} the collection's Space, for the default * pull axis (zcap revocation); required together with `revoke` unless a * custom `pull` is supplied * @param options.recipientId {string} the removed reader's key-agreement key * id (`kid`), dropped from the new epoch's recipients * @param [options.revoke] {IDelegatedZcap | IDelegatedZcap[]} the reader's * delegated capability/capabilities to revoke (the default pull axis); * required together with `space` unless a custom `pull` is supplied * @param [options.pull] {function} a caller-supplied pull action replacing * the default zcap revocation. Runs only after the rotation is durable, and * should tolerate being re-run (removeRecipient is retried to convergence). * Mutually exclusive with `space` / `revoke`. * @param [options.resolveRecipientKey] {function} resolves a remaining * recipient's `kid` to its public key-agreement key, so the fresh epoch key * can be wrapped to it. Defaults to a `did:key` resolver (the `kid` fragment * is the X25519 public key); override for recipients whose `kid` is not a * self-describing `did:key`. May resolve `null` to signal drop-this-kid: * the rotation then excludes that entry from the fresh epoch instead of * throwing (subject to the no-recipients-remaining guard). * @returns {Promise} the new descriptor */ export declare function removeRecipient({ collection, store, space, recipientId, revoke, pull, resolveRecipientKey }: { collection?: Collection; store?: EncryptionDescriptorStore; space?: Space; recipientId: string; revoke?: IDelegatedZcap | IDelegatedZcap[]; pull?: () => Promise; resolveRecipientKey?: (kid: string) => Promise; }): Promise; /** * Replaces one reader (or several) with another (or several) in ONE * descriptor write -- the * shape of a key rotation cascading over a collection (e.g. a per-user key * replaced by its successor): each incoming recipient is escrowed into EVERY * epoch (history included, {@link addRecipient}'s semantics) and the current * epoch is rotated off the retiring recipient(s) ({@link removeRecipient}'s * semantics), in a single compare-and-swap. Two requests total (the read and * the CAS write) against the four a compose of addRecipient + removeRecipient * would cost, and no intermediate state in which both keys are current. * * Idempotent to convergence like its two halves: an epoch already carrying an * incoming recipient is left untouched; when additionally no retiring * recipient remains in the current epoch, nothing is written at all -- a naive * re-run after a crash appends zero redundant epochs. An escrow-only state * (an incoming recipient missing from some epoch but no retiring recipient * current) writes the escrow wraps without minting an epoch. * * The pull-axis contract is {@link removeRecipient}'s verbatim: the default * zcap revocation (`space` + `revoke`) or a caller-supplied `pull` action, * run only after the rotation is durable. A caller whose pull axis has * already run elsewhere (e.g. a DID-document edit under a current-key-set * rule) passes a no-op `pull`. * * The limits of the rotation are unchanged: nothing is re-encrypted, so a * retired key still opens every pre-rotation epoch it was a recipient of. * * A blinded-index HMAC key on the descriptor follows the same shape in the same * write: the incoming recipient gains a wrap entry, the retiring kid(s) lose * theirs, and the key itself never rotates. * * @param options {object} * @param [options.collection] {Collection} the collection whose Description * hosts the descriptor; exactly one of `collection` / `store` * @param [options.store] {EncryptionDescriptorStore} an explicit descriptor store * @param [options.space] {Space} the default pull axis, with `revoke` * @param options.retire {string | string[]} the retiring recipient kid(s), * dropped from the fresh epoch's roster * @param options.recipient {RecipientPublicKey | RecipientPublicKey[]} the * incoming reader(s)' public key-agreement key(s), each escrowed into every * epoch and wrapped into the fresh one * @param options.owner {object} the caller's own key material * @param options.owner.keyAgreementKey {IKeyAgreementKey} unwraps each epoch * key for the escrow -- it must be a recipient of every epoch (a retiring * key that was escrowed everywhere qualifies) * @param [options.revoke] {IDelegatedZcap | IDelegatedZcap[]} the default * pull axis, with `space` * @param [options.pull] {function} a caller-supplied pull action; mutually * exclusive with `space` / `revoke` * @param [options.resolveRecipientKey] {function} resolves a remaining * recipient's kid for the fresh epoch, `null` to drop it -- the * {@link removeRecipient} contract (the incoming recipients never route * through it) * @returns {Promise} the new descriptor */ export declare function replaceRecipient({ collection, store, space, retire, recipient, owner, revoke, pull, resolveRecipientKey }: { collection?: Collection; store?: EncryptionDescriptorStore; space?: Space; retire: string | string[]; recipient: RecipientPublicKey | RecipientPublicKey[]; owner: { keyAgreementKey: IKeyAgreementKey; }; revoke?: IDelegatedZcap | IDelegatedZcap[]; pull?: () => Promise; resolveRecipientKey?: (kid: string) => Promise; }): Promise; //# sourceMappingURL=recipients.d.ts.map