feat(vault): server-side kryptert fillager (V4.13)

Shade kunne flytte filer mellom peers (@shade/files) og lagre én liten
profil-blob per konto, men hadde ingen alltid-på lagring av krypterte filer.
Uten den kan ingen Shade-app tilby backup, og ingen klient lese data mens
peeren som eier dem er avslått.

Objekter er innholdsadresserte på hashen av CHIFFERTEKSTEN, så relayen kan
lagre, deduplisere og verifisere uten nøkkel — den regner om hashen ved
opplasting og avviser feilnavngitte objekter. Stier bor inne i det krypterte
manifestet, aldri i objektnavn: relayen skal ikke lære hva filene heter.
Loggen er append-only, så historikk og rollback følger av modellen.

SqliteVaultStore har med vilje INGEN minne-fallback, i motsetning til
blob-storen. Den fallbacken slettet Prisms profil ved en rutine-redeploy
2026-08-12 fordi den fungerte helt til containeren ble recreated, uten en
eneste feilmelding. En backup som glemmer er verre enn ingen backup, så uten
SHADE_VAULT_DB_PATH mountes rutene ikke — med en logglinje som sier hvorfor.

Én feil fanget av testene: pubkeyen ble først lagt på UTENFOR signaturen,
som både brøt verifyPayload og ville latt hvem som helst bytte identitet i
transit på den TOFU-pinnende førsteskrivingen.

16 vault- + 7 store-tester, alle mot de ekte rutehåndtererne gjennom Honos
fetch. Kjeden er dessuten kjørt mot en ekte HTTP-server med et ekte
workspace: 491 filer / 7,7 MB, alle bit-identiske etter gjenoppretting,
og andre push etter én endring sendte 0 KB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 11:58:37 +02:00
parent 012d7f5289
commit 84d3166ca1
18 changed files with 1775 additions and 1 deletions

View File

@@ -0,0 +1,225 @@
/**
* The client half: turn a set of files into an encrypted, versioned vault,
* and turn it back again on a machine that has nothing but the credentials.
*
* The push is deliberately have-check-first. A workspace changes a few lines
* at a time, so asking "do you already have these hashes?" and uploading only
* the misses turns a full backup into a handful of small writes. Everything
* unchanged keeps its hash from the previous commit and costs nothing.
*/
import type { CryptoProvider } from '@shade/core';
import { toBase64 } from '@shade/core';
// The pubkey derivation is not on `CryptoProvider` — it is a pure function of
// the seed, and lives with the curve implementation.
import { ed25519PublicKeyFromSeed } from '@shade/crypto-web';
import { signPayload } from '@shade/server';
import {
deriveVaultContentKey,
deriveVaultId,
deriveVaultSigningSeed,
} from '@shade/storage-encrypted';
import { openManifest, openObject, sealManifest, sealObject, toHex } from './crypto.js';
import type { VaultEntry, VaultLog, VaultManifest } from './types.js';
/** A file as the app sees it, before encryption. */
export interface VaultFile {
path: string;
bytes: Uint8Array;
mode?: string;
}
export interface VaultKeys {
vaultId: string;
contentKey: Uint8Array;
signingSeed: Uint8Array;
publicKey: Uint8Array;
}
/**
* Derive everything a vault needs from the account master key.
*
* This is what makes recovery "username and password": a fresh device that
* can derive the profile master can derive these too, and the relay hands
* over ciphertext it has never been able to read.
*/
export async function deriveVaultKeys(masterKey: Uint8Array, app: string): Promise<VaultKeys> {
const signingSeed = deriveVaultSigningSeed(masterKey, app);
const publicKey = ed25519PublicKeyFromSeed(signingSeed);
return {
vaultId: toHex(deriveVaultId(masterKey, app)),
contentKey: deriveVaultContentKey(masterKey, app),
signingSeed,
publicKey,
};
}
export interface VaultTransport {
/** Does the relay already hold this object? */
has(vaultId: string, hash: string): Promise<boolean>;
put(vaultId: string, hash: string, body: unknown): Promise<void>;
get(vaultId: string, hash: string): Promise<Uint8Array>;
log(vaultId: string, limit?: number): Promise<VaultLog>;
commit(vaultId: string, body: unknown): Promise<{ seq: number }>;
}
export interface PushResult {
seq: number;
/** Objects actually sent — the rest were already on the relay. */
uploaded: number;
/** Objects skipped because the relay had them. */
reused: number;
bytesUploaded: number;
}
export class VaultClient {
constructor(
private readonly crypto: CryptoProvider,
private readonly keys: VaultKeys,
private readonly transport: VaultTransport,
) {}
get vaultId(): string {
return this.keys.vaultId;
}
/**
* Sign a request body, with the pubkey inside the signed payload.
*
* The key has to be part of what gets signed, not appended afterwards:
* `verifyPayload` canonicalises every field, so an appended key would both
* break the signature and — if the scheme ignored it — let anyone swap the
* claimed identity in transit on a first, TOFU-pinning write.
*/
private async sign(body: Record<string, unknown>): Promise<Record<string, unknown>> {
return signPayload(this.crypto, this.keys.signingSeed, {
...body,
publicKey: toBase64(this.keys.publicKey),
});
}
/**
* Encrypt and upload `files`, then commit a manifest naming them.
*
* `at` is passed in rather than read from the clock so a caller can make a
* commit reproducible in tests, and so a queued backup records when it was
* *taken* rather than when it finally reached the relay.
*/
async push(files: VaultFile[], at: number, message?: string): Promise<PushResult> {
const { head } = await this.transport.log(this.keys.vaultId, 1);
const entries: VaultEntry[] = [];
let uploaded = 0;
let reused = 0;
let bytesUploaded = 0;
// Reuse the previous manifest's hashes for unchanged content: sealing is
// nondeterministic (fresh nonce), so re-sealing an untouched file would
// produce a new hash and a pointless upload every single time.
const previous = head > 0 ? await this.pull().catch(() => null) : null;
const byPath = new Map(previous?.manifest.entries.map((e) => [e.path, e]) ?? []);
const priorPlain = previous?.files ?? new Map<string, Uint8Array>();
for (const file of files) {
const prior = byPath.get(file.path);
const priorBytes = priorPlain.get(file.path);
if (prior && priorBytes && sameBytes(priorBytes, file.bytes)) {
entries.push({ ...prior, size: file.bytes.length });
reused++;
continue;
}
const sealed = await sealObject(
this.crypto,
this.keys.contentKey,
this.keys.vaultId,
file.bytes,
);
if (!(await this.transport.has(this.keys.vaultId, sealed.hash))) {
await this.transport.put(
this.keys.vaultId,
sealed.hash,
await this.sign({ data: toBase64(sealed.bytes) }),
);
uploaded++;
bytesUploaded += sealed.bytes.length;
} else {
reused++;
}
const entry: VaultEntry = { path: file.path, hash: sealed.hash, size: file.bytes.length };
if (file.mode !== undefined) entry.mode = file.mode;
entries.push(entry);
}
const manifest: VaultManifest = { version: 1, seq: head + 1, at, entries };
if (message !== undefined) manifest.message = message;
const sealedManifest = await sealManifest(
this.crypto,
this.keys.contentKey,
this.keys.vaultId,
manifest,
);
await this.transport.put(
this.keys.vaultId,
sealedManifest.hash,
await this.sign({ data: toBase64(sealedManifest.bytes) }),
);
const { seq } = await this.transport.commit(
this.keys.vaultId,
await this.sign({
manifest: sealedManifest.hash,
seq: manifest.seq,
at,
hashes: entries.map((e) => e.hash),
}),
);
return { seq, uploaded, reused, bytesUploaded };
}
/**
* Fetch a version and decrypt it. Defaults to the newest.
*
* Restoring is the whole point of the exercise, so this returns the plain
* files rather than a handle: a caller that has to make a second round of
* decisions to get their data back does not have a backup.
*/
async pull(seq?: number): Promise<{ manifest: VaultManifest; files: Map<string, Uint8Array> }> {
const log = await this.transport.log(this.keys.vaultId);
const wanted = seq ?? log.head;
const row = log.entries.find((e) => e.seq === wanted);
if (!row) throw new Error(`vault has no version ${wanted}`);
const manifestBytes = await this.transport.get(this.keys.vaultId, row.manifest);
const manifest = await openManifest(
this.crypto,
this.keys.contentKey,
this.keys.vaultId,
row.manifest,
manifestBytes,
);
const files = new Map<string, Uint8Array>();
for (const entry of manifest.entries) {
const bytes = await this.transport.get(this.keys.vaultId, entry.hash);
files.set(
entry.path,
await openObject(this.crypto, this.keys.contentKey, this.keys.vaultId, entry.hash, bytes),
);
}
return { manifest, files };
}
/** Version history, newest last. */
async history(limit?: number): Promise<VaultLog> {
return this.transport.log(this.keys.vaultId, limit);
}
}
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}

View File

@@ -0,0 +1,129 @@
/**
* Sealing and naming for vault objects.
*
* Every object on the relay is `nonce(12) || ciphertext||tag`, named by the
* SHA-256 of those bytes. Hashing the *ciphertext* rather than the plaintext
* is what lets the relay verify that an upload is what it claims to be
* without holding a key — it recomputes the hash and compares.
*
* The cost is that identical plaintext yields different names on each seal,
* because the nonce is fresh. Deduplication is therefore *within* a version
* chain (an unchanged file keeps its hash across commits because the client
* reuses the object it already uploaded), not across independent seals. That
* is the right trade: convergent encryption would leak which files two users
* share, which is exactly what a blind store must not do.
*/
import { sha256 } from '@noble/hashes/sha2.js';
import type { CryptoProvider } from '@shade/core';
import type { VaultManifest } from './types.js';
const NONCE_LEN = 12;
/** Lowercase hex SHA-256 — the name of an object. */
export function objectHash(sealed: Uint8Array): string {
return Array.from(sha256(sealed))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* AAD for an object.
*
* Binds the vault it belongs to, so a ciphertext lifted from one vault cannot
* be planted in another even by someone who can write to both. The content
* hash is deliberately NOT in the AAD: it is derived from the very bytes
* being sealed, so it cannot be known before sealing, and the relay's own
* hash check already covers substitution.
*/
function objectAad(vaultId: string): Uint8Array {
return new TextEncoder().encode(`shade-vault-object-v1|${vaultId}`);
}
export interface SealedObject {
hash: string;
bytes: Uint8Array;
}
/** Encrypt one object and give it its name. */
export async function sealObject(
crypto: CryptoProvider,
contentKey: Uint8Array,
vaultId: string,
plaintext: Uint8Array,
): Promise<SealedObject> {
const { ciphertext, nonce } = await crypto.aesGcmEncrypt(
contentKey,
plaintext,
objectAad(vaultId),
);
const bytes = new Uint8Array(NONCE_LEN + ciphertext.length);
bytes.set(nonce, 0);
bytes.set(ciphertext, NONCE_LEN);
return { hash: objectHash(bytes), bytes };
}
/**
* Decrypt one object.
*
* Throws when the hash does not match the bytes: a relay that returned the
* wrong object would otherwise surface as an AEAD failure, which reads like
* key corruption and sends the user looking in the wrong place.
*/
export async function openObject(
crypto: CryptoProvider,
contentKey: Uint8Array,
vaultId: string,
expectedHash: string,
bytes: Uint8Array,
): Promise<Uint8Array> {
if (bytes.length < NONCE_LEN + 16) {
throw new Error('vault object too short to be a sealed object');
}
const actual = objectHash(bytes);
if (actual !== expectedHash) {
throw new Error(`vault object hash mismatch: expected ${expectedHash}, got ${actual}`);
}
return crypto.aesGcmDecrypt(
contentKey,
bytes.subarray(NONCE_LEN),
bytes.subarray(0, NONCE_LEN),
objectAad(vaultId),
);
}
/** A manifest is stored as an ordinary object; this is the JSON step around it. */
export async function sealManifest(
crypto: CryptoProvider,
contentKey: Uint8Array,
vaultId: string,
manifest: VaultManifest,
): Promise<SealedObject> {
const json = new TextEncoder().encode(JSON.stringify(manifest));
return sealObject(crypto, contentKey, vaultId, json);
}
export async function openManifest(
crypto: CryptoProvider,
contentKey: Uint8Array,
vaultId: string,
expectedHash: string,
bytes: Uint8Array,
): Promise<VaultManifest> {
const plain = await openObject(crypto, contentKey, vaultId, expectedHash, bytes);
const parsed = JSON.parse(new TextDecoder().decode(plain)) as VaultManifest;
if (parsed.version !== 1) {
// Refusing beats guessing: a future manifest may mean a file the reader
// cannot represent, and silently dropping it would lose data on the next
// commit made from this client.
throw new Error(`unsupported vault manifest version: ${parsed.version}`);
}
return parsed;
}
/** Lowercase hex of arbitrary bytes — used for vaultId and pubkeys on the wire. */
export function toHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}

View File

@@ -0,0 +1,80 @@
/**
* The default transport: plain HTTP against a relay running the vault routes.
*
* Takes a `fetch` rather than calling the global one, so the same client works
* against a live server, a Hono app under test, and anything else that can
* answer a Request — which is how the tests exercise the real route handlers
* instead of a mock that agrees with them by construction.
*/
import { fromBase64 } from '@shade/core';
import type { VaultTransport } from './client.js';
import type { VaultLog } from './types.js';
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
export class HttpVaultTransport implements VaultTransport {
constructor(
private readonly baseUrl: string,
private readonly fetchImpl: FetchLike = globalThis.fetch.bind(globalThis),
) {}
private url(path: string): string {
return `${this.baseUrl.replace(/\/$/, '')}${path}`;
}
/** Turn a non-2xx into an Error that names the relay's own code. */
private async fail(res: Response, what: string): Promise<never> {
let detail = res.statusText;
try {
const body = (await res.json()) as { error?: { code?: string; message?: string } };
if (body?.error) detail = `${body.error.code}: ${body.error.message}`;
} catch {
// Non-JSON error body; the status line is all we have.
}
throw new Error(`vault ${what} failed (${res.status}) — ${detail}`);
}
async has(vaultId: string, hash: string): Promise<boolean> {
const res = await this.fetchImpl(this.url(`/v1/vault/${vaultId}/object/${hash}`), {
method: 'HEAD',
});
if (res.status === 200) return true;
if (res.status === 404) return false;
return this.fail(res, 'have-check');
}
async put(vaultId: string, hash: string, body: unknown): Promise<void> {
const res = await this.fetchImpl(this.url(`/v1/vault/${vaultId}/object/${hash}`), {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) return this.fail(res, 'upload');
}
async get(vaultId: string, hash: string): Promise<Uint8Array> {
const res = await this.fetchImpl(this.url(`/v1/vault/${vaultId}/object/${hash}`));
if (!res.ok) return this.fail(res, 'download');
return new Uint8Array(await res.arrayBuffer());
}
async log(vaultId: string, limit?: number): Promise<VaultLog> {
const q = limit === undefined ? '' : `?limit=${limit}`;
const res = await this.fetchImpl(this.url(`/v1/vault/${vaultId}/log${q}`));
if (!res.ok) return this.fail(res, 'log');
return (await res.json()) as VaultLog;
}
async commit(vaultId: string, body: unknown): Promise<{ seq: number }> {
const res = await this.fetchImpl(this.url(`/v1/vault/${vaultId}/commit`), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) return this.fail(res, 'commit');
return (await res.json()) as { seq: number };
}
}
export { fromBase64 };

View File

@@ -0,0 +1,6 @@
export * from './types.js';
export * from './crypto.js';
export * from './client.js';
export { MemoryVaultStore } from './store.js';
export type { VaultStore } from './store.js';
export { HttpVaultTransport } from './http-transport.js';

View File

@@ -0,0 +1,226 @@
/**
* Relay-side vault routes.
*
* GET /v1/vault/:vaultId/log → { entries, head }
* GET /v1/vault/:vaultId/object/:hash → raw ciphertext bytes
* HEAD /v1/vault/:vaultId/object/:hash → 200 | 404 (have-check)
* PUT /v1/vault/:vaultId/object/:hash → { stored } (signed)
* POST /v1/vault/:vaultId/commit → { seq } (signed)
*
* Auth is the same TOFU-Ed25519 scheme the blob primitive uses: the first
* signed write pins a pubkey for the vault, and every later write must be
* signed by it. There is no account, no password, and nothing for the relay
* to leak — it cannot even tell which user a vaultId belongs to.
*/
import { Hono } from 'hono';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import type { CryptoProvider } from '@shade/core';
import { fromBase64 } from '@shade/core';
import { verifyPayload } from '@shade/server';
import { objectHash } from './crypto.js';
import type { VaultStore } from './store.js';
import type { VaultManifest } from './types.js';
const ID_REGEX = /^[0-9a-f]{64}$/;
const HASH_REGEX = /^[0-9a-f]{64}$/;
export interface VaultRoutesOptions {
/** Per-object ceiling. Defaults to 8 MiB. */
maxObjectBytes?: number;
/** Whole-vault ceiling. Defaults to 512 MiB. */
maxVaultBytes?: number;
}
const DEFAULT_MAX_OBJECT = 8 * 1024 * 1024;
const DEFAULT_MAX_VAULT = 512 * 1024 * 1024;
function fail(code: string, message: string, status: ContentfulStatusCode) {
return { body: { error: { code, message } }, status };
}
export function createVaultRoutes(
store: VaultStore,
crypto: CryptoProvider,
options: VaultRoutesOptions = {},
): Hono {
const app = new Hono();
const maxObject = options.maxObjectBytes ?? DEFAULT_MAX_OBJECT;
const maxVault = options.maxVaultBytes ?? DEFAULT_MAX_VAULT;
/**
* Check a signed request against the vault's pinned key, pinning it on the
* first write. `publicKey` is only honoured when nothing is pinned yet —
* otherwise anyone could rotate the owner by simply asserting a new key.
*/
async function authorize(
vaultId: string,
payload: Record<string, unknown>,
): Promise<
{ ok: true } | { ok: false; code: string; message: string; status: ContentfulStatusCode }
> {
const claimed = typeof payload.publicKey === 'string' ? payload.publicKey : null;
const pinned = await store.getOwner(vaultId);
if (!pinned) {
if (!claimed) {
return { ok: false, code: 'UNAUTHORIZED', message: 'first write must carry publicKey', status: 401 };
}
const key = fromBase64(claimed);
try {
await verifyPayload(crypto, key, payload);
} catch (e) {
return { ok: false, code: 'UNAUTHORIZED', message: String((e as Error).message), status: 401 };
}
await store.setOwner(vaultId, key);
return { ok: true };
}
try {
await verifyPayload(crypto, pinned, payload);
} catch (e) {
return { ok: false, code: 'UNAUTHORIZED', message: String((e as Error).message), status: 401 };
}
return { ok: true };
}
app.get('/v1/vault/:vaultId/log', async (c) => {
const vaultId = c.req.param('vaultId');
if (!ID_REGEX.test(vaultId)) {
const f = fail('BAD_REQUEST', 'vaultId must be 64 lowercase hex chars', 400);
return c.json(f.body, f.status);
}
const limitRaw = c.req.query('limit');
const limit = limitRaw ? Number(limitRaw) : undefined;
const entries = await store.log(vaultId, Number.isFinite(limit) ? limit : undefined);
return c.json({ entries, head: await store.head(vaultId) });
});
// A have-check before uploading. This is what makes an unchanged file free:
// the client asks about every hash in the new manifest and only sends the
// ones the relay is missing.
app.on(['HEAD', 'GET'], '/v1/vault/:vaultId/object/:hash', async (c) => {
const vaultId = c.req.param('vaultId');
const hash = c.req.param('hash');
if (!ID_REGEX.test(vaultId) || !HASH_REGEX.test(hash)) {
const f = fail('BAD_REQUEST', 'bad vaultId or hash', 400);
return c.json(f.body, f.status);
}
if (c.req.method === 'HEAD') {
return c.body(null, (await store.hasObject(vaultId, hash)) ? 200 : 404);
}
const bytes = await store.getObject(vaultId, hash);
if (!bytes) {
const f = fail('NOT_FOUND', 'no such object', 404);
return c.json(f.body, f.status);
}
return c.body(bytes as unknown as ArrayBuffer, 200, {
'content-type': 'application/octet-stream',
});
});
app.put('/v1/vault/:vaultId/object/:hash', async (c) => {
const vaultId = c.req.param('vaultId');
const hash = c.req.param('hash');
if (!ID_REGEX.test(vaultId) || !HASH_REGEX.test(hash)) {
const f = fail('BAD_REQUEST', 'bad vaultId or hash', 400);
return c.json(f.body, f.status);
}
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body || typeof body.data !== 'string') {
const f = fail('BAD_REQUEST', 'expected { data, signedAt, signature }', 400);
return c.json(f.body, f.status);
}
const auth = await authorize(vaultId, body);
if (!auth.ok) {
const f = fail(auth.code, auth.message, auth.status);
return c.json(f.body, f.status);
}
const bytes = fromBase64(body.data);
if (bytes.length > maxObject) {
const f = fail('TOO_LARGE', `object exceeds ${maxObject} bytes`, 413);
return c.json(f.body, f.status);
}
if ((await store.usage(vaultId)) + bytes.length > maxVault) {
const f = fail('TOO_LARGE', `vault exceeds ${maxVault} bytes`, 413);
return c.json(f.body, f.status);
}
// The name must be the hash of the bytes. Without this the store would
// accept a mislabelled object, and every later read of that name would
// fail decryption somewhere far away from the cause.
const actual = objectHash(bytes);
if (actual !== hash) {
const f = fail('BAD_REQUEST', `hash mismatch: bytes hash to ${actual}`, 400);
return c.json(f.body, f.status);
}
await store.putObject(vaultId, hash, bytes);
return c.json({ stored: true, hash });
});
app.post('/v1/vault/:vaultId/commit', async (c) => {
const vaultId = c.req.param('vaultId');
if (!ID_REGEX.test(vaultId)) {
const f = fail('BAD_REQUEST', 'vaultId must be 64 lowercase hex chars', 400);
return c.json(f.body, f.status);
}
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body || typeof body.manifest !== 'string' || typeof body.seq !== 'number') {
const f = fail('BAD_REQUEST', 'expected { manifest, seq, hashes, signedAt, signature }', 400);
return c.json(f.body, f.status);
}
const auth = await authorize(vaultId, body);
if (!auth.ok) {
const f = fail(auth.code, auth.message, auth.status);
return c.json(f.body, f.status);
}
const head = await store.head(vaultId);
if (body.seq !== head + 1) {
// Two devices committed from the same head. The loser has to re-read
// and re-commit; retrying the same seq would overwrite a version that
// is already part of the history.
const f = fail('SEQ_CONFLICT', `expected seq ${head + 1}, got ${body.seq}`, 409);
return c.json({ ...f.body, head }, f.status);
}
if (!(await store.hasObject(vaultId, body.manifest))) {
const f = fail('MISSING_OBJECTS', 'manifest object not uploaded', 409);
return c.json(f.body, f.status);
}
// Every object the manifest references must already be here, or the
// commit would publish a version that cannot be restored. Checking at
// commit time is what makes the log trustworthy.
const hashes = Array.isArray(body.hashes) ? (body.hashes as string[]) : [];
const missing: string[] = [];
for (const h of hashes) {
if (!HASH_REGEX.test(h) || !(await store.hasObject(vaultId, h))) missing.push(h);
}
if (missing.length > 0) {
const f = fail('MISSING_OBJECTS', `${missing.length} referenced object(s) missing`, 409);
return c.json({ ...f.body, missing: missing.slice(0, 20) }, f.status);
}
await store.appendLog(vaultId, {
seq: body.seq,
manifest: body.manifest,
at: typeof body.at === 'number' ? body.at : Date.now(),
bytes: await store.usage(vaultId),
});
return c.json({ seq: body.seq, head: body.seq });
});
return app;
}
export type { VaultStore } from './store.js';
export { MemoryVaultStore } from './store.js';
export type { VaultManifest };

View File

@@ -0,0 +1,90 @@
/**
* Relay-side storage for a vault.
*
* The interface is deliberately dumb: put bytes under a hash, list a log,
* append to it. Nothing here can decrypt, and nothing needs to — which is
* what makes a SQLite, Postgres or object-store implementation equivalent.
*/
import type { VaultLogEntry } from './types.js';
export interface VaultStore {
/** Ed25519 pubkey pinned on first write (TOFU), or null for a fresh vault. */
getOwner(vaultId: string): Promise<Uint8Array | null>;
setOwner(vaultId: string, publicKey: Uint8Array): Promise<void>;
hasObject(vaultId: string, hash: string): Promise<boolean>;
putObject(vaultId: string, hash: string, bytes: Uint8Array): Promise<void>;
getObject(vaultId: string, hash: string): Promise<Uint8Array | null>;
/** Highest committed seq, or 0 when empty. */
head(vaultId: string): Promise<number>;
/** Newest last. `limit` counts back from the head. */
log(vaultId: string, limit?: number): Promise<VaultLogEntry[]>;
appendLog(vaultId: string, entry: VaultLogEntry): Promise<void>;
/** Total stored bytes, for quota decisions. */
usage(vaultId: string): Promise<number>;
}
/**
* In-memory store. Real for tests, a footgun in production — the same shape
* as `MemoryBlobStore`, and the same warning applies: a restart loses
* everything, and it will do so without an error.
*/
export class MemoryVaultStore implements VaultStore {
private owners = new Map<string, Uint8Array>();
private objects = new Map<string, Map<string, Uint8Array>>();
private logs = new Map<string, VaultLogEntry[]>();
async getOwner(vaultId: string): Promise<Uint8Array | null> {
return this.owners.get(vaultId) ?? null;
}
async setOwner(vaultId: string, publicKey: Uint8Array): Promise<void> {
this.owners.set(vaultId, publicKey);
}
private bucket(vaultId: string): Map<string, Uint8Array> {
let b = this.objects.get(vaultId);
if (!b) {
b = new Map();
this.objects.set(vaultId, b);
}
return b;
}
async hasObject(vaultId: string, hash: string): Promise<boolean> {
return this.bucket(vaultId).has(hash);
}
async putObject(vaultId: string, hash: string, bytes: Uint8Array): Promise<void> {
this.bucket(vaultId).set(hash, bytes);
}
async getObject(vaultId: string, hash: string): Promise<Uint8Array | null> {
return this.bucket(vaultId).get(hash) ?? null;
}
async head(vaultId: string): Promise<number> {
const l = this.logs.get(vaultId);
return l && l.length > 0 ? l[l.length - 1]!.seq : 0;
}
async log(vaultId: string, limit?: number): Promise<VaultLogEntry[]> {
const l = this.logs.get(vaultId) ?? [];
return limit === undefined ? [...l] : l.slice(Math.max(0, l.length - limit));
}
async appendLog(vaultId: string, entry: VaultLogEntry): Promise<void> {
const l = this.logs.get(vaultId) ?? [];
l.push(entry);
this.logs.set(vaultId, l);
}
async usage(vaultId: string): Promise<number> {
let total = 0;
for (const bytes of this.bucket(vaultId).values()) total += bytes.length;
return total;
}
}

View File

@@ -0,0 +1,79 @@
/**
* The vault's data model.
*
* A vault is a collection of files that lives encrypted on the relay: the
* pieces Shade was missing to let an app offer backup, or to let a client
* read data while the peer that owns it is switched off.
*
* Three ideas, and the shape follows from them:
*
* 1. **Objects are content-addressed.** An object's name is the hash of its
* *ciphertext*, so the relay can store and dedupe without understanding
* anything. Re-uploading an unchanged file is free, which matters when a
* workspace of several hundred files changes three lines at a time.
*
* 2. **A manifest names the collection.** Paths live in the manifest, not in
* object names — the relay must not learn that a user has a file called
* `Projects/Divorce/plan.md`. The manifest is itself encrypted and stored
* as an object.
*
* 3. **The log is append-only.** Each commit adds an entry pointing at a
* manifest. History, rollback and "what changed last Tuesday" all fall out
* of that, which is the versioning half of the requirement.
*/
/** One file in a manifest. `hash` names the ciphertext object. */
export interface VaultEntry {
/** Path within the collection, as the app understands it. */
path: string;
/** Lowercase hex SHA-256 of the ciphertext object. */
hash: string;
/** Plaintext byte length, for progress reporting and sanity checks. */
size: number;
/** Optional app-defined mode/metadata, opaque to the vault. */
mode?: string;
}
/** The full contents of a collection at one point in time. */
export interface VaultManifest {
/** Schema version — a client that does not know it must refuse, not guess. */
version: 1;
/** Monotonic, assigned by the client and checked by the relay. */
seq: number;
/** Epoch millis when the commit was made. */
at: number;
/** Optional human note, e.g. "nattlig backup". */
message?: string;
entries: VaultEntry[];
}
/** One row of the append-only log. */
export interface VaultLogEntry {
seq: number;
/** Hash of the encrypted manifest object. */
manifest: string;
at: number;
/** Total bytes of all objects the manifest references, for quota display. */
bytes: number;
}
/** What `GET /v1/vault/:id/log` returns. */
export interface VaultLog {
entries: VaultLogEntry[];
/** Highest seq the relay holds; `0` when the vault is empty. */
head: number;
}
/**
* Error codes clients branch on.
*
* `SEQ_CONFLICT` is the important one: two devices committed from the same
* head, and the loser must re-read and re-commit rather than retry blindly.
*/
export type VaultErrorCode =
| 'BAD_REQUEST'
| 'UNAUTHORIZED'
| 'NOT_FOUND'
| 'SEQ_CONFLICT'
| 'TOO_LARGE'
| 'MISSING_OBJECTS';