# `@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:") contentKey = HKDF(masterKey, "shade-vault-content-v1:") sigSeed = HKDF(masterKey, "shade-vault-sig-v1:") ``` 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.