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:
@@ -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",
|
||||
|
||||
@@ -31,6 +31,9 @@ export {
|
||||
deriveNonce,
|
||||
buildAad,
|
||||
hkdfDerive,
|
||||
deriveBlobSlotId,
|
||||
deriveBlobKey,
|
||||
deriveBlobSigningSeed,
|
||||
} from './crypto/kdf.js';
|
||||
export {
|
||||
AEAD_NONCE_LEN,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ export {
|
||||
deriveNonce,
|
||||
buildAad,
|
||||
hkdfDerive,
|
||||
deriveBlobSlotId,
|
||||
deriveBlobKey,
|
||||
deriveBlobSigningSeed,
|
||||
} from './crypto/kdf.js';
|
||||
export {
|
||||
AEAD_NONCE_LEN,
|
||||
|
||||
50
packages/shade-storage-encrypted/tests/blob-vectors.test.ts
Normal file
50
packages/shade-storage-encrypted/tests/blob-vectors.test.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user