227 lines
8.4 KiB
TypeScript
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 };
|