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,120 @@
/**
* The SQLite vault store, with persistence as the headline property.
*
* This exists because of 2026-08-12: the blob store fell back to memory when
* its path was unset, worked perfectly, and lost every profile on the next
* container recreate — with no error anywhere. A backup store that forgets is
* worse than none, so "survives a close and reopen" is tested directly rather
* than assumed from the fact that SQLite is involved.
*/
import { describe, test, expect, afterEach } from 'bun:test';
import { unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { SqliteVaultStore } from '../src/sqlite-vault-store.js';
const paths: string[] = [];
function scratch(name: string): string {
const p = join(tmpdir(), `shade-vault-${name}-${process.pid}-${Math.random().toString(36).slice(2)}.db`);
paths.push(p);
return p;
}
afterEach(() => {
for (const p of paths.splice(0)) {
for (const suffix of ['', '-wal', '-shm']) {
try {
unlinkSync(p + suffix);
} catch {
// Not every WAL sidecar exists; absence is fine.
}
}
}
});
const VAULT = 'a'.repeat(64);
const HASH = 'b'.repeat(64);
describe('persistence', () => {
test('objects, owner and log survive a close and reopen', async () => {
const path = scratch('reopen');
const first = new SqliteVaultStore(path);
await first.setOwner(VAULT, new Uint8Array([1, 2, 3, 4]));
await first.putObject(VAULT, HASH, new Uint8Array([9, 8, 7]));
await first.appendLog(VAULT, { seq: 1, manifest: HASH, at: 1700, bytes: 3 });
first.close();
const second = new SqliteVaultStore(path);
expect(await second.getOwner(VAULT)).toEqual(new Uint8Array([1, 2, 3, 4]));
expect(await second.getObject(VAULT, HASH)).toEqual(new Uint8Array([9, 8, 7]));
expect(await second.head(VAULT)).toBe(1);
expect((await second.log(VAULT))[0]!.manifest).toBe(HASH);
second.close();
});
test('binary content round-trips without base64 mangling', async () => {
// Objects are ciphertext: every byte value occurs, including 0x00.
const path = scratch('binary');
const store = new SqliteVaultStore(path);
const bytes = new Uint8Array(256);
for (let i = 0; i < 256; i++) bytes[i] = i;
await store.putObject(VAULT, HASH, bytes);
store.close();
const reopened = new SqliteVaultStore(path);
expect(await reopened.getObject(VAULT, HASH)).toEqual(bytes);
reopened.close();
});
});
describe('semantics', () => {
test('an empty vault has head 0 and no owner', async () => {
const store = new SqliteVaultStore(scratch('empty'));
expect(await store.head(VAULT)).toBe(0);
expect(await store.getOwner(VAULT)).toBeNull();
expect(await store.log(VAULT)).toEqual([]);
expect(await store.usage(VAULT)).toBe(0);
store.close();
});
test('re-putting the same hash is a no-op, not a duplicate', async () => {
// The name IS the content hash, so a repeat is the same bytes by
// definition. Usage must not double.
const store = new SqliteVaultStore(scratch('dupe'));
await store.putObject(VAULT, HASH, new Uint8Array(100));
await store.putObject(VAULT, HASH, new Uint8Array(100));
expect(await store.usage(VAULT)).toBe(100);
store.close();
});
test('vaults are isolated from each other', async () => {
const store = new SqliteVaultStore(scratch('isolated'));
const other = 'c'.repeat(64);
await store.putObject(VAULT, HASH, new Uint8Array([1]));
expect(await store.hasObject(other, HASH)).toBe(false);
expect(await store.getObject(other, HASH)).toBeNull();
expect(await store.usage(other)).toBe(0);
store.close();
});
test('the log is ordered oldest-first and limit counts back from the head', async () => {
const store = new SqliteVaultStore(scratch('log'));
for (let seq = 1; seq <= 5; seq++) {
await store.appendLog(VAULT, { seq, manifest: `${seq}`.repeat(64), at: seq, bytes: seq });
}
expect((await store.log(VAULT)).map((e) => e.seq)).toEqual([1, 2, 3, 4, 5]);
// A client asking for the last two wants 4 and 5, still in order.
expect((await store.log(VAULT, 2)).map((e) => e.seq)).toEqual([4, 5]);
expect(await store.head(VAULT)).toBe(5);
store.close();
});
test('a duplicate seq is rejected by the primary key', async () => {
// The route layer refuses this first, but the store is the last line:
// two rows with the same seq would make history ambiguous.
const store = new SqliteVaultStore(scratch('seq'));
await store.appendLog(VAULT, { seq: 1, manifest: HASH, at: 1, bytes: 1 });
expect(store.appendLog(VAULT, { seq: 1, manifest: HASH, at: 2, bytes: 1 })).rejects.toThrow();
store.close();
});
});