Ships the Prism FR (encrypted-profile-storage-v4.9.md) as a generic relay-side encrypted blob primitive: deterministically-located, AEAD-sealed blobs keyed by a 32-byte slotId derived client-side via HKDF from the user's master key. Unlocks credential-only bootstrap of new devices into existing E2EE state — no QR, no physical access. Server: BlobStore interface + Memory/Sqlite/Postgres impls, createBlobRoutes for GET/PUT/DELETE /v1/blob/:slotId with TOFU pubkey auth and If-Match CAS (409/412 semantics). Mounted on the same Hono app as the inbox; SHADE_BLOB_PG_URL / SHADE_BLOB_DB_PATH / SHADE_DISABLE_BLOB env-var plumbing in standalone. SDK: createProfileNamespace high-level wrapper (HKDF derivation, random-nonce AEAD seal, slotId-bound AAD) + low-level BlobClient. Cross-platform test vectors in test-vectors/blob-storage.json. New errors: ConflictError (409), PreconditionFailedError (412). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
211 lines
7.5 KiB
TypeScript
211 lines
7.5 KiB
TypeScript
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: `"<app-id>-<purpose>-<schema-version>"`, 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).
|
|
* - `<etag-string>` : 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<ProfileGetResult | null>;
|
|
put(plaintext: Uint8Array | string, options?: ProfilePutOptions): Promise<ProfilePutResult>;
|
|
delete(): Promise<boolean>;
|
|
}
|
|
|
|
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<typeof BlobClient>[0] = {
|
|
baseUrl: options.baseUrl,
|
|
crypto: options.crypto,
|
|
};
|
|
if (options.fetch) clientOptions.fetch = options.fetch;
|
|
const client = new BlobClient(clientOptions);
|
|
|
|
return {
|
|
slotIdHex,
|
|
|
|
async get(): Promise<ProfileGetResult | null> {
|
|
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<ProfilePutResult> {
|
|
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<BlobClient['put']>[0] = {
|
|
slotIdHex,
|
|
blob: sealed,
|
|
signingPrivateKey: signingSeed,
|
|
ownerPubkey,
|
|
};
|
|
if (options?.ifMatch !== undefined) putArgs.ifMatch = options.ifMatch;
|
|
return client.put(putArgs);
|
|
},
|
|
|
|
async delete(): Promise<boolean> {
|
|
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);
|
|
}
|