Files
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

91 lines
3.0 KiB
TypeScript

/**
* Relay-side storage for a vault.
*
* The interface is deliberately dumb: put bytes under a hash, list a log,
* append to it. Nothing here can decrypt, and nothing needs to — which is
* what makes a SQLite, Postgres or object-store implementation equivalent.
*/
import type { VaultLogEntry } from './types.js';
export interface VaultStore {
/** Ed25519 pubkey pinned on first write (TOFU), or null for a fresh vault. */
getOwner(vaultId: string): Promise<Uint8Array | null>;
setOwner(vaultId: string, publicKey: Uint8Array): Promise<void>;
hasObject(vaultId: string, hash: string): Promise<boolean>;
putObject(vaultId: string, hash: string, bytes: Uint8Array): Promise<void>;
getObject(vaultId: string, hash: string): Promise<Uint8Array | null>;
/** Highest committed seq, or 0 when empty. */
head(vaultId: string): Promise<number>;
/** Newest last. `limit` counts back from the head. */
log(vaultId: string, limit?: number): Promise<VaultLogEntry[]>;
appendLog(vaultId: string, entry: VaultLogEntry): Promise<void>;
/** Total stored bytes, for quota decisions. */
usage(vaultId: string): Promise<number>;
}
/**
* In-memory store. Real for tests, a footgun in production — the same shape
* as `MemoryBlobStore`, and the same warning applies: a restart loses
* everything, and it will do so without an error.
*/
export class MemoryVaultStore implements VaultStore {
private owners = new Map<string, Uint8Array>();
private objects = new Map<string, Map<string, Uint8Array>>();
private logs = new Map<string, VaultLogEntry[]>();
async getOwner(vaultId: string): Promise<Uint8Array | null> {
return this.owners.get(vaultId) ?? null;
}
async setOwner(vaultId: string, publicKey: Uint8Array): Promise<void> {
this.owners.set(vaultId, publicKey);
}
private bucket(vaultId: string): Map<string, Uint8Array> {
let b = this.objects.get(vaultId);
if (!b) {
b = new Map();
this.objects.set(vaultId, b);
}
return b;
}
async hasObject(vaultId: string, hash: string): Promise<boolean> {
return this.bucket(vaultId).has(hash);
}
async putObject(vaultId: string, hash: string, bytes: Uint8Array): Promise<void> {
this.bucket(vaultId).set(hash, bytes);
}
async getObject(vaultId: string, hash: string): Promise<Uint8Array | null> {
return this.bucket(vaultId).get(hash) ?? null;
}
async head(vaultId: string): Promise<number> {
const l = this.logs.get(vaultId);
return l && l.length > 0 ? l[l.length - 1]!.seq : 0;
}
async log(vaultId: string, limit?: number): Promise<VaultLogEntry[]> {
const l = this.logs.get(vaultId) ?? [];
return limit === undefined ? [...l] : l.slice(Math.max(0, l.length - limit));
}
async appendLog(vaultId: string, entry: VaultLogEntry): Promise<void> {
const l = this.logs.get(vaultId) ?? [];
l.push(entry);
this.logs.set(vaultId, l);
}
async usage(vaultId: string): Promise<number> {
let total = 0;
for (const bytes of this.bucket(vaultId).values()) total += bytes.length;
return total;
}
}