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