Files
Shade/docs/vault.md

127 lines
4.9 KiB
Markdown
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
# `@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
```ts
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
```ts
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.