314 lines
12 KiB
TypeScript
314 lines
12 KiB
TypeScript
|
|
/**
|
||
|
|
* 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'));
|
||
|
|
});
|
||
|
|
});
|