import type { CryptoProvider } from '@shade/core'; import { ValidationError } from '@shade/core'; import { deriveBlobSlotId, deriveBlobKey, deriveBlobSigningSeed, aeadSeal, aeadOpen, } from '@shade/storage-encrypted/crypto'; import { ed25519PublicKeyFromSeed } from '@shade/crypto-web'; import { BlobClient, slotIdToHex } from '@shade/inbox'; /** * V4.9 — relay-side encrypted profile storage. * * The `Profile` namespace lets a Shade-based app store a small, * AEAD-sealed JSON blob on the relay keyed by a deterministic slotId * derived from the user's master key. A brand new device that knows * only the credentials (password + PIN → masterKey via the existing * `@shade/storage-encrypted` KDF) can locate, decrypt, and update the * blob. The relay sees only opaque slotIds and AEAD-sealed bytes — it * never decrypts and cannot link slots to users. * * This is the *primitive* Prism uses for credential-driven device * linking (Phase 2 of the Prism device-linking plan): the blob holds * the list of paired hosts, the new device reads it, picks the first * online host, and starts a link-request handshake. But it's * deliberately app-shaped — any Shade app needing a credential-only * bootstrap into existing E2EE state can use it. Pass a different * `app` namespace string per use-case so two apps under the same * master never collide on the same slot. * * Usage: * const km = await KeyManager.unlock(...); // existing v4.5 flow * const profile = createProfileNamespace({ * baseUrl: 'https://shade.example/', * crypto: new SubtleCryptoProvider(), * masterKey: km.masterKey, * app: 'prism-profile-v1', * }); * * const current = await profile.get(); * // -> { plaintext: Uint8Array, etag: string } | null * * await profile.put(JSON.stringify({ hosts: [...] }), { * ifMatch: current?.etag, * }); * * await profile.delete(); // "forget everything" */ export interface ProfileNamespaceOptions { /** Base URL of the Shade relay. */ baseUrl: string; /** CryptoProvider — typically a fresh SubtleCryptoProvider instance. */ crypto: CryptoProvider; /** * 32-byte master key, exactly the value you'd hand to * `@shade/storage-encrypted`'s row-codec — the existing v4.5 KDF * chain (passphrase + scrypt → masterKey, possibly upgraded with * argon2id over a PIN) lands you here. Profile storage uses HKDF * subderivations under separate `info` strings, so it can't leak * the storage encryption key or vice versa. */ masterKey: Uint8Array; /** * Per-app namespace string. Distinct apps under the same master key * MUST pass different values so they don't collide on the same slot. * Convention: `"--"`, e.g. * `"prism-profile-v1"`. */ app: string; /** Optional fetch override (defaults to globalThis.fetch). */ fetch?: typeof fetch; } export interface ProfileGetResult { /** Decrypted plaintext bytes. The shape is up to the caller. */ plaintext: Uint8Array; /** Pass back as `ifMatch` to do a CAS update. */ etag: string; /** Wall-clock ms when the relay last accepted a write. */ updatedAt: number; } export interface ProfilePutOptions { /** * - `undefined` : create-only. Slot must be empty (else 409). * - `` : compare-and-swap. Must match current etag (else 412). * - `'*'` : unconditional overwrite. Slot must already exist (else 412). */ ifMatch?: string; } export interface ProfilePutResult { /** True if this PUT created the slot, false if it updated an existing one. */ created: boolean; /** New etag after the write. */ etag: string; updatedAt: number; } export interface ProfileNamespace { readonly slotIdHex: string; get(): Promise; put(plaintext: Uint8Array | string, options?: ProfilePutOptions): Promise; delete(): Promise; } const TEXT = new TextEncoder(); const TEXT_DECODER = new TextDecoder(); export function createProfileNamespace( options: ProfileNamespaceOptions, ): ProfileNamespace { if (options.masterKey.length !== 32) { throw new ValidationError('masterKey must be 32 bytes'); } if (options.app.length === 0) { throw new ValidationError('app namespace must be non-empty'); } const slotIdBytes = deriveBlobSlotId(options.masterKey, options.app); const slotIdHex = slotIdToHex(slotIdBytes); const blobKey = deriveBlobKey(options.masterKey, options.app); const signingSeed = deriveBlobSigningSeed(options.masterKey, options.app); const ownerPubkey = ed25519PublicKeyFromSeed(signingSeed); // AAD binds the slotId into the AEAD seal: a relay returning the // wrong slot's blob (mistake or malice) fails to open. The slotId is // already part of the URL path, but binding it cryptographically // prevents any kind of cross-slot replay regardless of how the bytes // got to us. const aad = TEXT.encode(`shade-profile-aad-v1:${slotIdHex}`); const clientOptions: ConstructorParameters[0] = { baseUrl: options.baseUrl, crypto: options.crypto, }; if (options.fetch) clientOptions.fetch = options.fetch; const client = new BlobClient(clientOptions); return { slotIdHex, async get(): Promise { const result = await client.get(slotIdHex); if (!result) return null; // Deterministic 12-byte nonce from (slotId, etag): the relay // stores `nonce || ct||tag` as one blob, so the AEAD layer // pulls the nonce off the front. We don't pre-compute it — // aeadOpen handles the prefix automatically. const plaintext = await aeadOpen(blobKey, result.blob, aad); return { plaintext, etag: result.etag, updatedAt: result.updatedAt, }; }, async put( plaintext: Uint8Array | string, options?: ProfilePutOptions, ): Promise { const ptBytes = typeof plaintext === 'string' ? TEXT.encode(plaintext) : plaintext; // Random per-write 12-byte nonce. We don't reuse a deterministic // nonce because two consecutive writes of the same plaintext // (rare but possible — re-uploading after a transient error) // would otherwise reuse (key, nonce, plaintext), which is a // nonce-reuse condition for AES-GCM. A fresh random nonce per // PUT keeps each AEAD invocation unique. const nonce = clientOptions.crypto.randomBytes(12); const sealed = await aeadSeal(blobKey, nonce, ptBytes, aad); const putArgs: Parameters[0] = { slotIdHex, blob: sealed, signingPrivateKey: signingSeed, ownerPubkey, }; if (options?.ifMatch !== undefined) putArgs.ifMatch = options.ifMatch; return client.put(putArgs); }, async delete(): Promise { return client.delete({ slotIdHex, signingPrivateKey: signingSeed, }); }, }; } // Re-export the raw KDF helpers so apps that want to drive a custom // flow (skip the AEAD layer, use a different client, run interop // against a non-Shade relay) don't have to re-import from // `@shade/storage-encrypted/crypto`. export { deriveBlobSlotId, deriveBlobKey, deriveBlobSigningSeed, } from '@shade/storage-encrypted/crypto'; export { ed25519PublicKeyFromSeed } from '@shade/crypto-web'; export { slotIdToHex } from '@shade/inbox'; /** Decode a UTF-8 plaintext from a `ProfileGetResult`. */ export function profilePlaintextToString(result: ProfileGetResult): string { return TEXT_DECODER.decode(result.plaintext); }