/** * Magic-link-bound cross-user delegation grants. * * This module is the **core storage + encryption layer** that lets a * grantor issue a tier-DEK to a user whose KEK they do not know. The * trust bridge is provided by the `@noy-db/on-magic-link` package: * * 1. Grantor picks a grantee identity (user id + email handle). * 2. Grantor mints a magic-link token (ULID) via `createMagicLinkToken`. * 3. Grantor derives a **content key** + a **KEK** from * `(serverSecret, token, vault)` using HKDF-SHA256 with separate * `info` tags — both callers (grantor and grantee) can derive the * same keys given the same inputs. * 4. Grantor persists a record in `_magic_link_grants/`: * - envelope `_data` is AES-GCM encrypted under the content key * - the inner `wrappedDek` is AES-KW wrapped under the KEK * 5. Grantee receives the URL, derives the same content key + KEK, * loads the grant, decrypts the envelope, unwraps the tier DEK. * * ## Why a separate collection from `_delegations` * * `_delegations` envelopes are encrypted under a DEK shared across * every vault user (audit-visibility). External auditors / client * portal users have NO pre-existing keyring, so they cannot read that * DEK. Magic-link grants live in their own collection whose envelope * encryption is derived purely from the magic-link URL + server secret * — nothing else is required to decrypt. * * ## Batch grants * * One magic-link token may point to MULTIPLE grants (e.g. the client * portal case: invoices + payments + etax all share one link). Each * grant is persisted under a distinct record id: * * `` for the single-grant / primary entry * `:` for subsequent entries * * `listMagicLinkGrants(store, vault, token)` enumerates every record * whose id begins with `` so the claimant can materialize all * DEKs in one pass. * * ## Revocation * * `store.delete(vault, _magic_link_grants, )` immediately * invalidates the link — even if the URL was captured and the server * secret leaked, no payload remains to decrypt. * * @module */ import type { NoydbStore } from '../../kernel/types.js'; import type { UnlockedKeyring } from './keyring.js'; import { type EnclaveKey } from '../../kernel/enclave/index.js'; /** Reserved collection holding magic-link grant envelopes. */ export declare const MAGIC_LINK_GRANTS_COLLECTION = "_magic_link_grants"; /** HKDF `info` for the AES-GCM content key. Version-namespaced. */ export declare const MAGIC_LINK_CONTENT_INFO_PREFIX = "noydb-magic-link-content-v1:"; /** HKDF `info` for the AES-KW KEK. Matches `@noy-db/on-magic-link`. */ export declare const MAGIC_LINK_KEK_INFO_PREFIX = "noydb-magic-link-v1:"; /** * Decrypted payload of a magic-link grant record. Mirrors * `DelegationToken` in `team/delegation.ts` but tracked separately * because the two flows persist under different collections + envelope * encryption schemes. */ export interface MagicLinkGrantPayload { readonly id: string; readonly toUser: string; readonly fromUser: string; readonly tier: number; /** Collection name or `null` for the vault-wide tier DEK. */ readonly collection: string | null; /** Optional specific record id scope. */ readonly record?: string; /** ISO timestamp — grant expires at this instant. */ readonly until: string; /** AES-KW-wrapped tier DEK, unwrap with the magic-link KEK. */ readonly wrappedDek: string; /** ISO timestamp the grant was issued. */ readonly createdAt: string; /** Optional caller-provided label (surfaced in audit UIs). */ readonly note?: string; } export interface IssueMagicLinkGrantOptions { readonly toUser: string; readonly tier: number; readonly collection?: string; readonly record?: string; readonly until: Date | string; readonly note?: string; } export interface MagicLinkGrantRecord { /** Store record id — `` or `:` for batch entries. */ readonly recordId: string; readonly payload: MagicLinkGrantPayload; } /** * Derive the AES-GCM content key from the same HKDF inputs used for * the magic-link KEK. Different `info` suffix → domain-separated key. * * Exported so the `@noy-db/on-magic-link` package can share the exact * derivation path without cross-dependency between the two modules. */ export declare function deriveMagicLinkContentKey(serverSecret: string | Uint8Array, token: string, vault: string): Promise; /** * Persist a magic-link grant record. Caller derives + provides both * the content key and the KEK; this function performs the wrap/encrypt * and writes the envelope. * * `recordId` lets the caller use either the bare token (primary grant) * or a suffixed id (batch entry). The writer is responsible for * collision-avoidance across batch entries. */ export declare function writeMagicLinkGrant(store: NoydbStore, vault: string, grantor: UnlockedKeyring, contentKey: EnclaveKey, grantKek: EnclaveKey, recordId: string, opts: IssueMagicLinkGrantOptions): Promise; /** * Fetch + decrypt a single magic-link grant record by id. Returns null * when the record is absent OR when decryption fails (wrong server * secret, wrong vault, tampered envelope) — callers treat a null as * "this URL is not valid for this server". * * The returned payload's `wrappedDek` is still AES-KW-wrapped; the * caller unwraps it with the magic-link KEK to obtain the tier DEK. */ export declare function readMagicLinkGrantRecord(store: NoydbStore, vault: string, contentKey: EnclaveKey, recordId: string): Promise; /** * Enumerate every grant record sharing the magic-link `token` prefix * (i.e. the primary `` entry plus any `:*` batch entries). * Expired grants are still returned — the caller filters on `until`. */ export declare function listMagicLinkGrants(store: NoydbStore, vault: string, contentKey: EnclaveKey, token: string): Promise; /** * Unwrap the tier DEK from a grant payload using the magic-link KEK. * Thin wrapper around `unwrapKey` — provided so the claimant can avoid * importing `crypto.js` directly. */ export declare function unwrapMagicLinkGrant(payload: MagicLinkGrantPayload, grantKek: EnclaveKey): Promise; /** * Delete a magic-link grant (primary + every batch entry sharing the * token). Safe to call when nothing exists. */ export declare function revokeMagicLinkGrant(store: NoydbStore, vault: string, token: string): Promise; /** * Compose the batch-entry record id. `index === 0` → bare token. * Subsequent entries use `:` so `store.list()` can * enumerate them all by common prefix. */ export declare function magicLinkGrantRecordId(token: string, index: number): string; /** * True when the payload's `until` is in the past relative to `now`. * Kept here (rather than inlined) so the semantics stay aligned with * the canonical `DelegationToken` expiry check. */ export declare function isMagicLinkGrantExpired(payload: MagicLinkGrantPayload, now?: Date): boolean;