Files
Shade/packages/shade-storage-sqlite/src/sqlite-vault-store.ts

155 lines
5.8 KiB
TypeScript
Raw Normal View History

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
import { Database } from 'bun:sqlite';
import type { VaultLogEntry, VaultStore } from '@shade/vault';
/**
* SQLite-backed VaultStore for the V4.13 encrypted file store.
*
* Three tables, mirroring the model: an owner key per vault, the
* content-addressed objects, and the append-only manifest log. The relay
* never decrypts anything — it enforces auth, verifies that an object's bytes
* hash to its name, and refuses a commit whose objects are not all present.
*
* Objects are stored as BLOBs rather than base64 text. A workspace backup is
* a few hundred files where the blob primitive holds one small profile, so the
* 33% base64 overhead stops being a rounding error.
*
* Docker usage: set `SHADE_VAULT_DB_PATH` (falls back to `/data/shade-vault.db`).
* **Set it.** The blob store's equivalent was missing from `docker-compose.yml`
* for months and nobody noticed, because the in-memory fallback works
* perfectly until the container is recreated — at which point everything is
* gone, with no error anywhere. That happened on 2026-08-12.
*/
export class SqliteVaultStore implements VaultStore {
private db: Database;
private stmts!: {
getOwner: ReturnType<Database['prepare']>;
setOwner: ReturnType<Database['prepare']>;
hasObject: ReturnType<Database['prepare']>;
putObject: ReturnType<Database['prepare']>;
getObject: ReturnType<Database['prepare']>;
head: ReturnType<Database['prepare']>;
log: ReturnType<Database['prepare']>;
logLimit: ReturnType<Database['prepare']>;
appendLog: ReturnType<Database['prepare']>;
usage: ReturnType<Database['prepare']>;
};
constructor(dbPath?: string) {
const path = dbPath ?? process.env.SHADE_VAULT_DB_PATH ?? '/data/shade-vault.db';
this.db = new Database(path, { create: true });
this.db.exec('PRAGMA journal_mode=WAL');
this.ensureTables();
this.prepareStatements();
}
private ensureTables() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS shade_vault_owners (
vault_id TEXT PRIMARY KEY,
owner_pubkey BLOB NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS shade_vault_objects (
vault_id TEXT NOT NULL,
hash TEXT NOT NULL,
bytes BLOB NOT NULL,
size INTEGER NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (vault_id, hash)
);
CREATE TABLE IF NOT EXISTS shade_vault_log (
vault_id TEXT NOT NULL,
seq INTEGER NOT NULL,
manifest TEXT NOT NULL,
at INTEGER NOT NULL,
bytes INTEGER NOT NULL,
PRIMARY KEY (vault_id, seq)
);
`);
}
private prepareStatements() {
this.stmts = {
getOwner: this.db.prepare('SELECT owner_pubkey FROM shade_vault_owners WHERE vault_id = ?'),
setOwner: this.db.prepare(
'INSERT OR REPLACE INTO shade_vault_owners (vault_id, owner_pubkey, created_at) VALUES (?, ?, ?)',
),
hasObject: this.db.prepare(
'SELECT 1 FROM shade_vault_objects WHERE vault_id = ? AND hash = ? LIMIT 1',
),
// An object's name IS its content hash, so a repeat upload is the same
// bytes by definition — ignoring it is correct, not lossy.
putObject: this.db.prepare(
'INSERT OR IGNORE INTO shade_vault_objects (vault_id, hash, bytes, size, created_at) VALUES (?, ?, ?, ?, ?)',
),
getObject: this.db.prepare(
'SELECT bytes FROM shade_vault_objects WHERE vault_id = ? AND hash = ?',
),
head: this.db.prepare('SELECT MAX(seq) AS head FROM shade_vault_log WHERE vault_id = ?'),
log: this.db.prepare(
'SELECT seq, manifest, at, bytes FROM shade_vault_log WHERE vault_id = ? ORDER BY seq ASC',
),
logLimit: this.db.prepare(
'SELECT seq, manifest, at, bytes FROM (SELECT seq, manifest, at, bytes FROM shade_vault_log WHERE vault_id = ? ORDER BY seq DESC LIMIT ?) ORDER BY seq ASC',
),
appendLog: this.db.prepare(
'INSERT INTO shade_vault_log (vault_id, seq, manifest, at, bytes) VALUES (?, ?, ?, ?, ?)',
),
usage: this.db.prepare(
'SELECT COALESCE(SUM(size), 0) AS total FROM shade_vault_objects WHERE vault_id = ?',
),
};
}
async getOwner(vaultId: string): Promise<Uint8Array | null> {
const row = this.stmts.getOwner.get(vaultId) as { owner_pubkey: Uint8Array } | null;
return row ? new Uint8Array(row.owner_pubkey) : null;
}
async setOwner(vaultId: string, publicKey: Uint8Array): Promise<void> {
this.stmts.setOwner.run(vaultId, publicKey, Date.now());
}
async hasObject(vaultId: string, hash: string): Promise<boolean> {
return this.stmts.hasObject.get(vaultId, hash) !== null;
}
async putObject(vaultId: string, hash: string, bytes: Uint8Array): Promise<void> {
this.stmts.putObject.run(vaultId, hash, bytes, bytes.length, Date.now());
}
async getObject(vaultId: string, hash: string): Promise<Uint8Array | null> {
const row = this.stmts.getObject.get(vaultId, hash) as { bytes: Uint8Array } | null;
return row ? new Uint8Array(row.bytes) : null;
}
async head(vaultId: string): Promise<number> {
const row = this.stmts.head.get(vaultId) as { head: number | null } | null;
return row?.head ?? 0;
}
async log(vaultId: string, limit?: number): Promise<VaultLogEntry[]> {
const rows = (
limit === undefined
? this.stmts.log.all(vaultId)
: this.stmts.logLimit.all(vaultId, limit)
) as VaultLogEntry[];
return rows;
}
async appendLog(vaultId: string, entry: VaultLogEntry): Promise<void> {
this.stmts.appendLog.run(vaultId, entry.seq, entry.manifest, entry.at, entry.bytes);
}
async usage(vaultId: string): Promise<number> {
const row = this.stmts.usage.get(vaultId) as { total: number } | null;
return row?.total ?? 0;
}
close(): void {
this.db.close();
}
}