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