/** * 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')); }); });