release(v4.9.0): relay-side encrypted blob primitive + SDK Profile namespace
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>
This commit is contained in:
210
packages/shade-sdk/src/profile.ts
Normal file
210
packages/shade-sdk/src/profile.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user