Files
Shade/docs/vault.md
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

4.9 KiB

@shade/vault — server-side encrypted file store

V4.13. Where the blob primitive (V4.9) holds one small blob per account, a vault holds a whole collection — a workspace, a mailbox, a drive folder — as content-addressed objects plus an append-only log of manifests.

It is the piece Shade was missing to let an app offer backup, or to let a client read data while the peer that owns it is switched off.

The model

Objects are content-addressed. An object's name is the SHA-256 of its ciphertext, so the relay can store, dedupe and verify without a key: it recomputes the hash on upload and rejects anything mislabelled.

A manifest names the collection. Paths live inside the encrypted manifest, never in object names — the relay must not learn that a user has a file called Projects/Divorce/plan.md. The manifest is itself stored as an object.

The log is append-only. Each commit adds a row pointing at a manifest. History, rollback and "what changed last Tuesday" all fall out of that.

Keys

Three deterministic derivations off the account master key, in their own HKDF branch:

vaultId    = HKDF(masterKey, "shade-vault-id-v1:<app>")
contentKey = HKDF(masterKey, "shade-vault-content-v1:<app>")
sigSeed    = HKDF(masterKey, "shade-vault-sig-v1:<app>")

Separate from shade-blob-*-v1 on purpose: a vault key reads every file, a profile-blob key reads a host list. Sharing a derivation would turn one compromise into the other.

Because the derivation is deterministic, recovery is credentials. A fresh device that can derive the profile master can derive these, and the relay hands over ciphertext it has never been able to read. The flip side is that recovery strength is password strength — see G1's argon2id item.

Client

import { SubtleCryptoProvider } from '@shade/crypto-web';
import { VaultClient, HttpVaultTransport, deriveVaultKeys } from '@shade/vault';

const crypto = new SubtleCryptoProvider();
const keys = await deriveVaultKeys(masterKey, 'scaffold');
const client = new VaultClient(crypto, keys, new HttpVaultTransport('https://vault.example'));

// Back up. Unchanged files are not re-uploaded.
const res = await client.push(files, Date.now(), 'nightly');
// → { seq: 4, uploaded: 2, reused: 489, bytesUploaded: 1_204 }

// Restore — the newest, or any earlier version.
const { manifest, files: restored } = await client.pull();
const old = await client.pull(2);

// History.
const log = await client.history();

push takes at rather than reading the clock, so a queued backup records when it was taken rather than when it finally reached the relay.

Server

import { createVaultRoutes } from '@shade/vault/server';
import { SqliteVaultStore } from '@shade/storage-sqlite';

app.route('/', createVaultRoutes(new SqliteVaultStore('/data/shade-vault.db'), crypto));

Routes:

GET /v1/vault/:id/log { entries, head }
HEAD /v1/vault/:id/object/:hash 200 / 404 — the have-check
GET /v1/vault/:id/object/:hash raw ciphertext
PUT /v1/vault/:id/object/:hash signed; verifies hash matches bytes
POST /v1/vault/:id/commit signed; refuses a stale seq or a missing object

Auth is TOFU-Ed25519, as for blobs: the first signed write pins a pubkey, and every later write must be signed by it. The pubkey travels inside the signed payload — appended afterwards it would not be covered by the signature, and could be swapped in transit on the pinning write.

Configuration

SHADE_VAULT_DB_PATH=/data/shade-vault.db   # required — no in-memory fallback
SHADE_DISABLE_VAULT=1                       # optional: off entirely

There is deliberately no in-memory fallback. The blob store has one, and on 2026-08-12 a routine redeploy destroyed every profile because the path was never set in compose: the fallback worked perfectly right up until the container was recreated, and nothing ever failed loudly. A vault that forgets is worse than no vault, so an unset path means the routes are simply not mounted, with a warning that says why.

Limits

Per object 8 MiB, per vault 512 MiB, both overridable via VaultRoutesOptions.

What the relay learns

Object sizes, how many objects there are, when commits happen, and that some opaque 64-hex id is active. Not contents, not paths, not filenames, and not which user a vaultId belongs to — the id is itself derived from a secret.

Deduplication is within a version chain, not across independent seals: sealing uses a fresh nonce, so identical plaintext yields different names. Convergent encryption would dedupe globally but leak which files two users share, which is exactly what a blind store must not do.

Status

Client, routes, MemoryVaultStore and SqliteVaultStore are implemented and tested (16 unit + 7 store tests, plus an end-to-end suite against a real workspace of 491 files). Not yet: a Postgres store, rate limiting on the vault routes, and garbage collection of objects no manifest references any more.