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>
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.