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>
This commit is contained in:
313
packages/shade-vault/tests/vault.test.ts
Normal file
313
packages/shade-vault/tests/vault.test.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* End-to-end tests for the vault.
|
||||
*
|
||||
* The client talks to the REAL route handlers through Hono's `fetch`, not to a
|
||||
* mock. A mock transport would agree with the client by construction and prove
|
||||
* nothing about the wire contract — which is exactly the surface a phone and a
|
||||
* daemon have to share.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import { createVaultRoutes } from '../src/server.js';
|
||||
import { MemoryVaultStore } from '../src/store.js';
|
||||
import { HttpVaultTransport } from '../src/http-transport.js';
|
||||
import { VaultClient, deriveVaultKeys } from '../src/client.js';
|
||||
import { objectHash } from '../src/crypto.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
const AT = 1_786_600_000_000;
|
||||
|
||||
function enc(s: string): Uint8Array {
|
||||
return new TextEncoder().encode(s);
|
||||
}
|
||||
function dec(b: Uint8Array): string {
|
||||
return new TextDecoder().decode(b);
|
||||
}
|
||||
|
||||
/** A client wired to a fresh in-memory relay. */
|
||||
async function harness(masterKey = new Uint8Array(32).fill(7), app = 'scaffold') {
|
||||
const store = new MemoryVaultStore();
|
||||
const routes = createVaultRoutes(store, crypto);
|
||||
const transport = new HttpVaultTransport('http://vault.test', (input, init) =>
|
||||
routes.fetch(new Request(input, init)),
|
||||
);
|
||||
const keys = await deriveVaultKeys(masterKey, app);
|
||||
return { store, keys, client: new VaultClient(crypto, keys, transport) };
|
||||
}
|
||||
|
||||
describe('round trip', () => {
|
||||
test('files pushed can be pulled back byte-for-byte', async () => {
|
||||
const { client } = await harness();
|
||||
const files = [
|
||||
{ path: '.scaffold/plan.md', bytes: enc('# Plan\n\n## Nå\n- [ ] noe\n') },
|
||||
{ path: '.scaffold/tasks.yaml', bytes: enc('tasks: []\n') },
|
||||
];
|
||||
|
||||
const res = await client.push(files, AT, 'første backup');
|
||||
expect(res.seq).toBe(1);
|
||||
expect(res.uploaded).toBe(2);
|
||||
|
||||
const { manifest, files: back } = await client.pull();
|
||||
expect(manifest.seq).toBe(1);
|
||||
expect(manifest.message).toBe('første backup');
|
||||
expect(dec(back.get('.scaffold/plan.md')!)).toBe('# Plan\n\n## Nå\n- [ ] noe\n');
|
||||
expect(dec(back.get('.scaffold/tasks.yaml')!)).toBe('tasks: []\n');
|
||||
});
|
||||
|
||||
test('a fresh device with only the credentials can restore', async () => {
|
||||
// The recovery story: same master key, nothing else carried over.
|
||||
const master = new Uint8Array(32).fill(11);
|
||||
const { client, store } = await harness(master);
|
||||
await client.push([{ path: 'notes.yaml', bytes: enc('notes: [en, to]\n') }], AT);
|
||||
|
||||
const routes = createVaultRoutes(store, crypto);
|
||||
const transport = new HttpVaultTransport('http://vault.test', (i, init) =>
|
||||
routes.fetch(new Request(i, init)),
|
||||
);
|
||||
const keys = await deriveVaultKeys(master, 'scaffold');
|
||||
const fresh = new VaultClient(crypto, keys, transport);
|
||||
|
||||
const { files } = await fresh.pull();
|
||||
expect(dec(files.get('notes.yaml')!)).toBe('notes: [en, to]\n');
|
||||
});
|
||||
|
||||
test('a different master key cannot read the vault', async () => {
|
||||
const { store } = await harness(new Uint8Array(32).fill(1));
|
||||
const routes = createVaultRoutes(store, crypto);
|
||||
const transport = new HttpVaultTransport('http://vault.test', (i, init) =>
|
||||
routes.fetch(new Request(i, init)),
|
||||
);
|
||||
const wrong = await deriveVaultKeys(new Uint8Array(32).fill(2), 'scaffold');
|
||||
const intruder = new VaultClient(crypto, wrong, transport);
|
||||
// A different master derives a different vaultId, so there is nothing
|
||||
// there to read in the first place — the id is itself a secret.
|
||||
await expect(intruder.pull()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('versioning', () => {
|
||||
test('each push is a new version and old ones stay readable', async () => {
|
||||
const { client } = await harness();
|
||||
await client.push([{ path: 'plan.md', bytes: enc('versjon 1') }], AT);
|
||||
await client.push([{ path: 'plan.md', bytes: enc('versjon 2') }], AT + 1000);
|
||||
await client.push([{ path: 'plan.md', bytes: enc('versjon 3') }], AT + 2000);
|
||||
|
||||
const log = await client.history();
|
||||
expect(log.head).toBe(3);
|
||||
expect(log.entries.map((e) => e.seq)).toEqual([1, 2, 3]);
|
||||
|
||||
// Rollback: the whole point of keeping the log.
|
||||
expect(dec((await client.pull(1)).files.get('plan.md')!)).toBe('versjon 1');
|
||||
expect(dec((await client.pull(2)).files.get('plan.md')!)).toBe('versjon 2');
|
||||
expect(dec((await client.pull()).files.get('plan.md')!)).toBe('versjon 3');
|
||||
});
|
||||
|
||||
test('unchanged files are not re-uploaded', async () => {
|
||||
// The property that makes backing up a 9 MB workspace on every change
|
||||
// affordable: only what actually moved goes over the wire.
|
||||
const { client } = await harness();
|
||||
const stable = { path: 'stor-logg.md', bytes: enc('x'.repeat(50_000)) };
|
||||
|
||||
const first = await client.push([stable, { path: 'plan.md', bytes: enc('en') }], AT);
|
||||
expect(first.uploaded).toBe(2);
|
||||
|
||||
const second = await client.push([stable, { path: 'plan.md', bytes: enc('to') }], AT + 1);
|
||||
expect(second.uploaded).toBe(1);
|
||||
expect(second.reused).toBe(1);
|
||||
expect(second.bytesUploaded).toBeLessThan(1000);
|
||||
|
||||
// And the reused file is still intact in the new version.
|
||||
const { files } = await client.pull();
|
||||
expect(files.get('stor-logg.md')!.length).toBe(50_000);
|
||||
});
|
||||
|
||||
test('a deleted file is absent from the new version but present in the old', async () => {
|
||||
const { client } = await harness();
|
||||
await client.push(
|
||||
[
|
||||
{ path: 'a.md', bytes: enc('A') },
|
||||
{ path: 'b.md', bytes: enc('B') },
|
||||
],
|
||||
AT,
|
||||
);
|
||||
await client.push([{ path: 'a.md', bytes: enc('A') }], AT + 1);
|
||||
|
||||
expect((await client.pull()).files.has('b.md')).toBe(false);
|
||||
expect(dec((await client.pull(1)).files.get('b.md')!)).toBe('B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the relay is blind', () => {
|
||||
test('stored objects contain no plaintext', async () => {
|
||||
const { client, store } = await harness();
|
||||
await client.push([{ path: 'hemmelig/plan.md', bytes: enc('SENSITIVT INNHOLD') }], AT);
|
||||
|
||||
const log = await store.log(client.vaultId);
|
||||
const manifestBytes = await store.getObject(client.vaultId, log[0]!.manifest);
|
||||
const asText = dec(manifestBytes!);
|
||||
// Neither the contents nor the path leaks: paths live inside the
|
||||
// encrypted manifest, not in object names.
|
||||
expect(asText).not.toContain('SENSITIVT');
|
||||
expect(asText).not.toContain('hemmelig');
|
||||
});
|
||||
|
||||
test('object names are the hash of the ciphertext, so the relay can verify', async () => {
|
||||
const { client, store } = await harness();
|
||||
await client.push([{ path: 'x', bytes: enc('hei') }], AT);
|
||||
const log = await store.log(client.vaultId);
|
||||
const bytes = await store.getObject(client.vaultId, log[0]!.manifest);
|
||||
expect(objectHash(bytes!)).toBe(log[0]!.manifest);
|
||||
});
|
||||
|
||||
test('an object whose bytes do not match its name is rejected', async () => {
|
||||
const store = new MemoryVaultStore();
|
||||
const routes = createVaultRoutes(store, crypto);
|
||||
const keys = await deriveVaultKeys(new Uint8Array(32).fill(3), 'scaffold');
|
||||
const { signPayload } = await import('@shade/server');
|
||||
const { toBase64 } = await import('@shade/core');
|
||||
|
||||
const body = await signPayload(crypto, keys.signingSeed, {
|
||||
data: toBase64(enc('juks')),
|
||||
publicKey: toBase64(keys.publicKey),
|
||||
});
|
||||
const res = await routes.fetch(
|
||||
new Request(`http://v/v1/vault/${keys.vaultId}/object/${'0'.repeat(64)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error.code).toBe('BAD_REQUEST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent writers', () => {
|
||||
test('a commit from a stale head is refused', async () => {
|
||||
// Two devices push from the same version. The second must not be able to
|
||||
// overwrite a sequence number that is already history.
|
||||
const { client, keys, store } = await harness();
|
||||
await client.push([{ path: 'plan.md', bytes: enc('en')}], AT);
|
||||
|
||||
const routes = createVaultRoutes(store, crypto);
|
||||
const { signPayload } = await import('@shade/server');
|
||||
const { toBase64 } = await import('@shade/core');
|
||||
const log = await store.log(keys.vaultId);
|
||||
const body = await signPayload(crypto, keys.signingSeed, {
|
||||
manifest: log[0]!.manifest,
|
||||
seq: 1, // already taken
|
||||
at: AT,
|
||||
hashes: [],
|
||||
publicKey: toBase64(keys.publicKey),
|
||||
});
|
||||
|
||||
const res = await routes.fetch(
|
||||
new Request(`http://v/v1/vault/${keys.vaultId}/commit`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
const err = await res.json();
|
||||
expect(err.error.code).toBe('SEQ_CONFLICT');
|
||||
expect(err.head).toBe(1);
|
||||
});
|
||||
|
||||
test('a commit referencing a missing object is refused', async () => {
|
||||
// Otherwise the log would publish a version that cannot be restored.
|
||||
const { client, keys, store } = await harness();
|
||||
await client.push([{ path: 'plan.md', bytes: enc('en') }], AT);
|
||||
|
||||
const routes = createVaultRoutes(store, crypto);
|
||||
const { signPayload } = await import('@shade/server');
|
||||
const { toBase64 } = await import('@shade/core');
|
||||
const log = await store.log(keys.vaultId);
|
||||
const body = await signPayload(crypto, keys.signingSeed, {
|
||||
manifest: log[0]!.manifest,
|
||||
seq: 2,
|
||||
at: AT,
|
||||
hashes: ['a'.repeat(64)],
|
||||
publicKey: toBase64(keys.publicKey),
|
||||
});
|
||||
|
||||
const res = await routes.fetch(
|
||||
new Request(`http://v/v1/vault/${keys.vaultId}/commit`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).error.code).toBe('MISSING_OBJECTS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorisation', () => {
|
||||
test('a second key cannot write to a vault another key pinned', async () => {
|
||||
const { client, keys, store } = await harness();
|
||||
await client.push([{ path: 'plan.md', bytes: enc('mitt') }], AT);
|
||||
|
||||
const routes = createVaultRoutes(store, crypto);
|
||||
const { signPayload } = await import('@shade/server');
|
||||
const { toBase64 } = await import('@shade/core');
|
||||
const attacker = await deriveVaultKeys(new Uint8Array(32).fill(9), 'scaffold');
|
||||
|
||||
// Signed correctly — but by the wrong key, and asserting its own pubkey.
|
||||
const body = await signPayload(crypto, attacker.signingSeed, {
|
||||
data: toBase64(enc('tull')),
|
||||
publicKey: toBase64(attacker.publicKey),
|
||||
});
|
||||
const res = await routes.fetch(
|
||||
new Request(
|
||||
`http://v/v1/vault/${keys.vaultId}/object/${objectHash(enc('tull'))}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('an unsigned write is refused', async () => {
|
||||
const store = new MemoryVaultStore();
|
||||
const routes = createVaultRoutes(store, crypto);
|
||||
const res = await routes.fetch(
|
||||
new Request(`http://v/v1/vault/${'a'.repeat(64)}/object/${'b'.repeat(64)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ data: 'aGk=' }),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('key derivation', () => {
|
||||
test('the same credentials derive the same vault, different ones do not', async () => {
|
||||
const a = await deriveVaultKeys(new Uint8Array(32).fill(4), 'scaffold');
|
||||
const b = await deriveVaultKeys(new Uint8Array(32).fill(4), 'scaffold');
|
||||
const c = await deriveVaultKeys(new Uint8Array(32).fill(5), 'scaffold');
|
||||
expect(a.vaultId).toBe(b.vaultId);
|
||||
expect(a.vaultId).not.toBe(c.vaultId);
|
||||
});
|
||||
|
||||
test('two apps under one master do not share a vault', async () => {
|
||||
const master = new Uint8Array(32).fill(6);
|
||||
const scaffold = await deriveVaultKeys(master, 'scaffold');
|
||||
const mail = await deriveVaultKeys(master, 'mail');
|
||||
expect(scaffold.vaultId).not.toBe(mail.vaultId);
|
||||
expect(scaffold.contentKey).not.toEqual(mail.contentKey);
|
||||
});
|
||||
|
||||
test('the vault branch is separate from the profile-blob branch', async () => {
|
||||
// A vault key reads every file; a profile-blob key reads a host list.
|
||||
// Sharing a derivation would make one compromise into the other.
|
||||
const master = new Uint8Array(32).fill(8);
|
||||
const { deriveBlobKey } = await import('@shade/storage-encrypted');
|
||||
const vault = await deriveVaultKeys(master, 'prism');
|
||||
expect(vault.contentKey).not.toEqual(deriveBlobKey(master, 'prism'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user