release(v4.9.0): relay-side encrypted blob primitive + SDK Profile namespace
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled

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:
2026-05-09 02:44:42 +02:00
parent 3c0db14904
commit 80c410f518
51 changed files with 2138 additions and 58 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/storage-encrypted",
"version": "4.8.5",
"version": "4.9.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -31,6 +31,9 @@ export {
deriveNonce,
buildAad,
hkdfDerive,
deriveBlobSlotId,
deriveBlobKey,
deriveBlobSigningSeed,
} from './crypto/kdf.js';
export {
AEAD_NONCE_LEN,

View File

@@ -122,3 +122,42 @@ export function deriveNonce(rowKey: Uint8Array, table: string, pk: string): Uint
export function buildAad(table: string, column: string, pk: string): Uint8Array {
return TEXT.encode(`shade-aad-v1|${table}|${column}|${pk}`);
}
// ─── V4.9 — relay-side encrypted blob primitive ──────────────
//
// Three deterministic 32-byte derivations rooted at the user's master
// key, used by `@shade/sdk`'s `Profile` namespace to bootstrap a brand
// new device into existing E2EE state from credentials alone:
//
// slotId = HKDF(masterKey, info=`shade-blob-slot-v1:${app}`)
// blobKey = HKDF(masterKey, info=`shade-blob-key-v1:${app}`)
// sigSeed = HKDF(masterKey, info=`shade-blob-sig-v1:${app}`)
//
// `app` is a caller-supplied namespace (e.g. `"prism-profile"`) so two
// Shade apps with the same user/master never collide on the same slot.
//
// The slot identifier and the AEAD key are *both* derived from the
// master — the relay sees opaque slotIds and AEAD-sealed blobs and
// cannot decrypt or correlate slots to users. The signing seed is the
// raw 32-byte Ed25519 private key (matches @noble/curves' API: pubkey
// = ed25519.getPublicKey(seed)).
/** Lower-hex 64-char slotId derived from the master key. */
export function deriveBlobSlotId(masterKey: Uint8Array, app: string): Uint8Array {
return hkdfDerive(masterKey, `shade-blob-slot-v1:${app}`, 32);
}
/** AEAD key for sealing/opening the blob. Use AAD = slotId. */
export function deriveBlobKey(masterKey: Uint8Array, app: string): Uint8Array {
return hkdfDerive(masterKey, `shade-blob-key-v1:${app}`, 32);
}
/**
* 32-byte Ed25519 signing seed (== the private key in the @noble/curves
* convention). The pubkey, derived deterministically from the seed, is
* what the relay TOFU-stores on the first PUT and verifies subsequent
* writes against.
*/
export function deriveBlobSigningSeed(masterKey: Uint8Array, app: string): Uint8Array {
return hkdfDerive(masterKey, `shade-blob-sig-v1:${app}`, 32);
}

View File

@@ -16,6 +16,9 @@ export {
deriveNonce,
buildAad,
hkdfDerive,
deriveBlobSlotId,
deriveBlobKey,
deriveBlobSigningSeed,
} from './crypto/kdf.js';
export {
AEAD_NONCE_LEN,

View File

@@ -0,0 +1,50 @@
import { describe, test, expect } from 'bun:test';
import { readFileSync } from 'fs';
import { join } from 'path';
import {
deriveBlobSlotId,
deriveBlobKey,
deriveBlobSigningSeed,
} from '../src/crypto/kdf.js';
import { ed25519PublicKeyFromSeed } from '@shade/crypto-web';
function fromHex(s: string): Uint8Array {
const out = new Uint8Array(s.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(s.substr(i * 2, 2), 16);
}
return out;
}
function toHex(b: Uint8Array): string {
let s = '';
for (const x of b) s += x.toString(16).padStart(2, '0');
return s;
}
describe('V4.9 blob-storage KDF vectors', () => {
// Resolve relative to this file, not to cwd, so the test passes
// regardless of which directory `bun test` is invoked from.
const vectorPath = join(import.meta.dir, '..', '..', '..', 'test-vectors', 'blob-storage.json');
const vectors = JSON.parse(readFileSync(vectorPath, 'utf-8')) as {
kdf: Array<{
masterKey: string;
app: string;
slotId: string;
blobKey: string;
signingSeed: string;
ownerPubkey: string;
}>;
};
for (const v of vectors.kdf) {
test(`(master=${v.masterKey.slice(0, 8)}…, app=${v.app})`, () => {
const km = fromHex(v.masterKey);
expect(toHex(deriveBlobSlotId(km, v.app))).toBe(v.slotId);
expect(toHex(deriveBlobKey(km, v.app))).toBe(v.blobKey);
const seed = deriveBlobSigningSeed(km, v.app);
expect(toHex(seed)).toBe(v.signingSeed);
expect(toHex(ed25519PublicKeyFromSeed(seed))).toBe(v.ownerPubkey);
});
}
});