Files
Shade/packages/shade-vault/src/server.ts
Stian 84d3166ca1 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>
2026-08-14 11:58:37 +02:00

227 lines
8.4 KiB
TypeScript

/**
* 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 };