feat(mana/web): encryption phase 3 — vault client + record helpers + layout wire-up

Adds the client-side wire-up that lets browsers fetch their master key
from the mana-auth server vault and use it to encrypt/decrypt configured
record fields. Still a no-op at the user-visible level until Phase 4
flips registry entries to enabled:true on a per-table basis.

vault-client.ts
  Browser HTTP client for the three Phase 2 endpoints. Built around a
  factory that takes (authUrl, getToken) and returns { unlock, lock,
  refetch, rotate, getState }. Reuses the active MemoryKeyProvider if
  one is already installed, otherwise registers a fresh one.

  unlock() flow:
    1. Short-circuits if already unlocked.
    2. GET /api/v1/me/encryption-vault/key with Bearer token.
    3. On 404 + code:'VAULT_NOT_INITIALISED', auto-fires POST /init so
       the user is bootstrapped on first login per device.
    4. Imports the returned base64 bytes via importMasterKey() into a
       non-extractable CryptoKey, pushes it into MemoryKeyProvider.
    5. Zeroes the raw byte buffer once imported (best-effort heap hygiene).

  Network layer: 3-attempt retry loop with full-jitter exponential
  backoff (500ms→8s), retries only on 0/408/429/5xx. 4xx surfaces
  immediately so auth/permission errors don't stall the UI for seconds.

  Error categorisation: 401/403→auth, network→network, 5xx→server,
  rest→unknown. Returned as VaultUnlockState so callers can render
  intent ("please re-login" vs "we're trying again" vs "the server
  is having a moment").

record-helpers.ts
  encryptRecord(tableName, record):
    - Looks up the registry, returns unchanged if the table is not
      configured or registry entry is disabled.
    - Builds a work list of fields that need encryption (skipping
      null/undefined and already-encrypted blobs — the latter makes
      the helper idempotent on a re-emit from liveQuery).
    - Throws VaultLockedError on the first call that needs the key
      but finds the vault locked. Module stores let it bubble; the
      UI surfaces "you need to unlock" toast.

  decryptRecord(tableName, record):
    - Mirror of encryptRecord. Locked-vault behaviour is to LEAVE the
      blobs in place (rather than throw) so views can still render
      structural fields and show a "🔒" placeholder where content
      used to be.
    - Per-field decrypt failure (corrupt blob, wrong key) is caught,
      logged, and the field stays encrypted. The rest of the record
      decrypts normally — one bad blob doesn't kill the whole read.

  decryptRecords: array variant that skips null/undefined entries.

Layout integration (+layout.svelte)
  - createVaultClient is constructed once at module init, reused
    across all auth-state changes.
  - The existing $effect on authStore.user gets a new branch:
    - userId set + hasAnyEncryption() → vaultClient.unlock()
    - userId cleared → vaultClient.lock()
  - hasAnyEncryption() guards the network round-trip: while every
    table is enabled:false (Phase 3 default), no fetch happens at all.
    Phase 4 enables tables one by one and the unlock kicks in
    automatically.

Tests
  - record-helpers.test.ts: 12 cases — encrypt skips non-listed fields,
    null/undefined pass-through, idempotent on already-encrypted,
    table-not-in-registry no-op, VaultLockedError on missing key,
    decrypt roundtrip, locked-vault returns blobs unchanged, per-field
    failure logged + others continue, JSON.stringify/parse roundtrip
    survives the sync wire.
  - vault-client.test.ts: 12 cases — happy path GET /key, idempotent
    second unlock, 404 → auto /init, generic 404 does NOT trigger
    /init, 401/403 → auth error, fetch throw → network error, no
    token → auth error without network call, lock() clears key,
    refetch() re-pulls, rotate() POSTs and installs.

Verified: 7 test files, 110/110 src/lib/data/ tests passing
(31 AES + 12 record-helpers + 12 vault-client + 20 sync + 6 activity
+ 19 recurrence + 10 misc helpers).

Phase 4 (next): pilot the notes module — flip its registry entry to
enabled:true, wrap the notes store add/update to call encryptRecord,
wrap the notes queries to call decryptRecord, add a settings page
showing lock state and a manual rotate button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 18:49:22 +02:00
parent c5aeaf5e7f
commit 354cbcb176
6 changed files with 840 additions and 0 deletions

View file

@ -43,3 +43,12 @@ export {
hasAnyEncryption, hasAnyEncryption,
getRegisteredTables, getRegisteredTables,
} from './registry'; } from './registry';
export { encryptRecord, decryptRecord, decryptRecords, VaultLockedError } from './record-helpers';
export {
type VaultClient,
type VaultClientOptions,
type VaultUnlockState,
createVaultClient,
} from './vault-client';

View file

@ -0,0 +1,190 @@
/**
* Tests for encryptRecord / decryptRecord roundtrip behaviour.
*
* Uses real Web Crypto from the Node 20+ runtime no Dexie or
* IndexedDB needed. The registry is mutated in place via vi.spyOn so
* we can flip a test table to enabled:true without affecting the
* production defaults.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { encryptRecord, decryptRecord, decryptRecords, VaultLockedError } from './record-helpers';
import { generateMasterKey, isEncrypted } from './aes';
import { MemoryKeyProvider, setKeyProvider } from './key-provider';
import * as registry from './registry';
let key: CryptoKey;
let provider: MemoryKeyProvider;
const TEST_TABLE = 'notes';
beforeEach(async () => {
key = await generateMasterKey();
provider = new MemoryKeyProvider();
provider.setKey(key);
setKeyProvider(provider);
// Pretend the notes table is enabled with title + body fields.
vi.spyOn(registry, 'getEncryptedFields').mockImplementation((tableName: string) => {
if (tableName === TEST_TABLE) return ['title', 'body'];
return null;
});
});
afterEach(() => {
vi.restoreAllMocks();
provider.setKey(null);
});
describe('encryptRecord', () => {
it('encrypts only the configured fields, leaves the rest plaintext', async () => {
const record = {
id: 'note-1',
title: 'Buy milk',
body: 'and eggs and bread',
createdAt: '2026-04-07T10:00:00Z',
isPinned: false,
};
await encryptRecord(TEST_TABLE, record);
expect(record.id).toBe('note-1');
expect(record.createdAt).toBe('2026-04-07T10:00:00Z');
expect(record.isPinned).toBe(false);
expect(isEncrypted(record.title)).toBe(true);
expect(isEncrypted(record.body)).toBe(true);
expect(record.title).not.toBe('Buy milk');
expect(record.body).not.toBe('and eggs and bread');
});
it('skips fields that are null or undefined', async () => {
const record = { id: 'n', title: null, body: undefined as unknown as string };
await encryptRecord(TEST_TABLE, record);
expect(record.title).toBe(null);
expect(record.body).toBe(undefined);
});
it('skips already-encrypted fields (idempotent on second call)', async () => {
const record = { id: 'n', title: 'first', body: 'second' };
await encryptRecord(TEST_TABLE, record);
const encryptedTitle = record.title;
const encryptedBody = record.body;
await encryptRecord(TEST_TABLE, record);
expect(record.title).toBe(encryptedTitle);
expect(record.body).toBe(encryptedBody);
});
it('returns unchanged when the table is not in the registry', async () => {
const record = { id: 'x', title: 'plain', body: 'plain' };
await encryptRecord('not_encrypted_table', record);
expect(record.title).toBe('plain');
expect(record.body).toBe('plain');
});
it('throws VaultLockedError when no key is available', async () => {
provider.setKey(null);
const record = { id: 'n', title: 'secret', body: 'also secret' };
await expect(encryptRecord(TEST_TABLE, record)).rejects.toThrow(VaultLockedError);
// Record was not partially mutated
expect(record.title).toBe('secret');
expect(record.body).toBe('also secret');
});
it('does not throw when the vault is locked but no fields need encryption', async () => {
provider.setKey(null);
const record = { id: 'n', title: null, body: undefined as unknown as string };
await expect(encryptRecord(TEST_TABLE, record)).resolves.toBeDefined();
});
});
describe('decryptRecord', () => {
it('decrypts the configured fields back to their plaintext', async () => {
const original = { id: 'n', title: 'Buy milk', body: 'plus eggs', createdAt: 'now' };
// We can't pass the same object to encrypt + decrypt without
// reading the encrypted values first, so encrypt in place then
// decrypt the same reference.
await encryptRecord(TEST_TABLE, original);
expect(isEncrypted(original.title)).toBe(true);
await decryptRecord(TEST_TABLE, original);
expect(original.title).toBe('Buy milk');
expect(original.body).toBe('plus eggs');
expect(original.createdAt).toBe('now');
});
it('leaves blobs in place when the vault is locked', async () => {
const record = { id: 'n', title: 'Buy milk', body: 'plus eggs' };
await encryptRecord(TEST_TABLE, record);
const encryptedTitle = record.title;
const encryptedBody = record.body;
provider.setKey(null);
await decryptRecord(TEST_TABLE, record);
expect(record.title).toBe(encryptedTitle);
expect(record.body).toBe(encryptedBody);
});
it('logs and continues on per-field decrypt failure', async () => {
const record = { id: 'n', title: 'Buy milk', body: 'plus eggs' };
await encryptRecord(TEST_TABLE, record);
// Tamper with the title's auth tag
const lastChar = (record.title as string).charAt((record.title as string).length - 1);
const swap = lastChar === 'A' ? 'B' : 'A';
record.title = (record.title as string).slice(0, -1) + swap;
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await decryptRecord(TEST_TABLE, record);
expect(errSpy).toHaveBeenCalledOnce();
// Body still decrypts
expect(record.body).toBe('plus eggs');
// Title stays as-is (corrupt blob)
expect(isEncrypted(record.title)).toBe(true);
errSpy.mockRestore();
});
it('passes through plaintext fields untouched', async () => {
const record = { id: 'n', title: 'plain string', body: null };
await decryptRecord(TEST_TABLE, record);
expect(record.title).toBe('plain string');
expect(record.body).toBe(null);
});
});
describe('decryptRecords', () => {
it('decrypts an array of records', async () => {
const records = [
{ id: 'a', title: 'A title', body: 'A body' },
{ id: 'b', title: 'B title', body: 'B body' },
];
await Promise.all(records.map((r) => encryptRecord(TEST_TABLE, r)));
const decrypted = await decryptRecords(TEST_TABLE, records);
expect(decrypted).toHaveLength(2);
expect(decrypted[0].title).toBe('A title');
expect(decrypted[1].title).toBe('B title');
});
it('skips null/undefined entries from getMany-style results', async () => {
const r = { id: 'a', title: 'A', body: 'B' };
await encryptRecord(TEST_TABLE, r);
const decrypted = await decryptRecords(TEST_TABLE, [r, null, undefined]);
expect(decrypted).toHaveLength(1);
expect(decrypted[0].title).toBe('A');
});
});
describe('encrypt → write → read → decrypt full cycle', () => {
it('survives a JSON.stringify/parse roundtrip in between (sync wire)', async () => {
const original = { id: 'n', title: 'Sensitive', body: 'Even more sensitive' };
await encryptRecord(TEST_TABLE, original);
// Simulate sending to the server and getting it back
const onTheWire = JSON.stringify(original);
const fromServer = JSON.parse(onTheWire) as typeof original;
await decryptRecord(TEST_TABLE, fromServer);
expect(fromServer.title).toBe('Sensitive');
expect(fromServer.body).toBe('Even more sensitive');
});
});

View file

@ -0,0 +1,143 @@
/**
* Record-level encrypt/decrypt helpers driven by the registry.
*
* Module stores call `encryptRecord(tableName, record)` before
* `table.add(record)` / `table.update(id, record)`. Module read paths
* call `decryptRecord(tableName, record)` after `table.get(id)` /
* `liveQuery(...)`.
*
* Why explicit calls instead of transparent Dexie hooks?
* - Dexie hooks are synchronous; Web Crypto is async. Wrapping the
* hook in a sync polyfill (stablelib) would add ~10 KB and slow
* every write down by ~3x. Explicit async calls keep the hot path
* on native crypto.
* - Pilot rollout is easier: notes can adopt encryption without
* touching todo, calendar, etc.
* - Tests are simpler: no need to mock Dexie hook timing.
*
* What happens if the vault is locked?
* - encryptRecord throws `VaultLockedError`. Module stores let it
* bubble; the UI layer turns it into a "you need to unlock first"
* toast. The user-visible write fails fast no plaintext sneaks
* through.
* - decryptRecord returns the record unchanged (encrypted blobs stay
* opaque), so views can still render the structural fields and
* show a "🔒 locked" placeholder where the encrypted ones used to
* be.
*
* What about server-applied changes?
* - applyServerChanges in sync.ts treats encrypted fields as opaque
* strings. The server stores blobs, the client stores blobs. The
* decrypt happens at READ time via decryptRecord, not at apply
* time. This means LWW continues to compare timestamps (plaintext
* metadata) without ever touching the ciphertext.
*/
import { wrapValue, unwrapValue, isEncrypted } from './aes';
import { getActiveKey, isVaultUnlocked } from './key-provider';
import { getEncryptedFields } from './registry';
/** Thrown by encryptRecord when no key is available. Module stores
* catch this to surface "vault locked" UI. */
export class VaultLockedError extends Error {
constructor(public tableName: string) {
super(`mana-crypto: vault is locked, cannot encrypt fields for table '${tableName}'`);
this.name = 'VaultLockedError';
}
}
/**
* Encrypts the configured fields of `record` in place. Returns the
* same record reference for chaining. No-op if the table is not in
* the registry, the registry entry is disabled, or every configured
* field is null/undefined.
*
* Throws VaultLockedError if at least one field would need encryption
* but no master key is available. Callers can pre-check with
* `isVaultUnlocked()` to surface a friendlier error.
*/
export async function encryptRecord<T extends Record<string, unknown>>(
tableName: string,
record: T
): Promise<T> {
const fields = getEncryptedFields(tableName);
if (!fields) return record;
// Build the work list first so we don't half-encrypt a record on
// vault-locked failure mid-loop.
const todo: string[] = [];
for (const field of fields) {
const value = record[field];
if (value === null || value === undefined) continue;
// Already encrypted? Skip — happens when applyServerChanges
// hands a record (with encrypted blobs from the wire) back
// through the same code path on a re-emit / liveQuery refresh.
if (typeof value === 'string' && isEncrypted(value)) continue;
todo.push(field);
}
if (todo.length === 0) return record;
const key = getActiveKey();
if (!key) throw new VaultLockedError(tableName);
for (const field of todo) {
const wrapped = await wrapValue(record[field], key);
(record as Record<string, unknown>)[field] = wrapped;
}
return record;
}
/**
* Decrypts the configured fields of `record` in place. Returns the
* same record reference. No-op if the table is not in the registry,
* the registry entry is disabled, or every encrypted field is missing.
*
* Locked-vault behaviour: leaves encrypted blobs in place rather than
* throwing. Views are expected to handle the blob "🔒" rendering
* themselves. Plaintext fields (id, timestamps, status) stay readable.
*/
export async function decryptRecord<T extends Record<string, unknown>>(
tableName: string,
record: T
): Promise<T> {
const fields = getEncryptedFields(tableName);
if (!fields) return record;
const key = getActiveKey();
if (!key) return record; // locked: leave blobs as-is
for (const field of fields) {
const value = record[field];
if (typeof value !== 'string' || !isEncrypted(value)) continue;
try {
(record as Record<string, unknown>)[field] = await unwrapValue(value, key);
} catch (err) {
// Don't kill the read just because one field is corrupt or
// keyed to a previous master. Log + leave the blob in place
// so the UI can show a "decryption failed" marker.
console.error(
`[mana-crypto] decrypt failed for ${tableName}.${field}: ${(err as Error).message}`
);
}
}
return record;
}
/**
* Convenience for liveQuery / array results applies decryptRecord
* to every entry and returns a fresh array of the same length. Skips
* null entries (e.g. from `getMany([id1, id2])` when one is missing).
*/
export async function decryptRecords<T extends Record<string, unknown>>(
tableName: string,
records: (T | null | undefined)[]
): Promise<T[]> {
const out: T[] = [];
for (const r of records) {
if (r) out.push(await decryptRecord(tableName, r));
}
return out;
}
/** Re-export so callers don't need to reach into key-provider.ts. */
export { isVaultUnlocked };

View file

@ -0,0 +1,217 @@
/**
* Tests for the browser-side vault client.
*
* Mocks `globalThis.fetch` to simulate the various HTTP responses
* mana-auth can return. The actual MemoryKeyProvider gets a real
* Web Crypto key imported via the response, so the test also
* verifies that the import path doesn't blow up on the bytes the
* server sends.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createVaultClient } from './vault-client';
import { MemoryKeyProvider, setKeyProvider, getActiveKey, isVaultUnlocked } from './key-provider';
import { generateMasterKey, exportMasterKey } from './aes';
let realFetch: typeof fetch;
beforeEach(() => {
realFetch = globalThis.fetch;
// Reset to a fresh provider for each test
setKeyProvider(new MemoryKeyProvider());
});
afterEach(() => {
globalThis.fetch = realFetch;
vi.restoreAllMocks();
});
/** Helper: builds a base64 string the server would return for a key. */
async function freshKeyBase64(): Promise<string> {
const key = await generateMasterKey();
const raw = await exportMasterKey(key);
let bin = '';
for (let i = 0; i < raw.length; i++) bin += String.fromCharCode(raw[i]);
return btoa(bin);
}
function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
return handler(url, init);
}) as typeof fetch;
}
describe('createVaultClient.unlock — happy path', () => {
it('fetches /key on first unlock and installs the master key', async () => {
const masterKey = await freshKeyBase64();
const calls: string[] = [];
mockFetch((url) => {
calls.push(url);
return new Response(JSON.stringify({ masterKey, formatVersion: 1, kekId: 'env-v1' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
});
const client = createVaultClient({
authUrl: 'http://localhost:3001',
getToken: () => 'test-jwt',
});
const state = await client.unlock();
expect(state).toEqual({ status: 'unlocked' });
expect(isVaultUnlocked()).toBe(true);
expect(getActiveKey()).not.toBe(null);
expect(calls).toEqual(['http://localhost:3001/api/v1/me/encryption-vault/key']);
});
it('is idempotent when already unlocked', async () => {
const masterKey = await freshKeyBase64();
mockFetch(() => new Response(JSON.stringify({ masterKey }), { status: 200 }));
const client = createVaultClient({
authUrl: 'http://localhost:3001',
getToken: () => 'test-jwt',
});
await client.unlock();
const fetchCount = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length;
const second = await client.unlock();
expect(second).toEqual({ status: 'unlocked' });
// Second call short-circuits without hitting the network
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(fetchCount);
});
});
describe('createVaultClient.unlock — auto-init on 404', () => {
it('calls /init when /key returns VAULT_NOT_INITIALISED', async () => {
const masterKey = await freshKeyBase64();
const calls: string[] = [];
mockFetch((url) => {
calls.push(url);
if (url.endsWith('/key')) {
return new Response(
JSON.stringify({ error: 'vault not initialised', code: 'VAULT_NOT_INITIALISED' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
if (url.endsWith('/init')) {
return new Response(JSON.stringify({ masterKey, formatVersion: 1, kekId: 'env-v1' }), {
status: 200,
});
}
return new Response('', { status: 500 });
});
const client = createVaultClient({
authUrl: 'http://localhost:3001',
getToken: () => 'test-jwt',
});
const state = await client.unlock();
expect(state).toEqual({ status: 'unlocked' });
expect(calls).toEqual([
'http://localhost:3001/api/v1/me/encryption-vault/key',
'http://localhost:3001/api/v1/me/encryption-vault/init',
]);
});
it('does NOT call /init on a generic 404 without the code', async () => {
mockFetch(() => new Response(JSON.stringify({ error: 'not found' }), { status: 404 }));
const client = createVaultClient({
authUrl: 'http://localhost:3001',
getToken: () => 'test-jwt',
});
const state = await client.unlock();
expect(state.status).toBe('error');
// Only the /key call happened — no /init follow-up
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
});
});
describe('createVaultClient.unlock — error categorisation', () => {
it('reports auth error on 401', async () => {
mockFetch(() => new Response('', { status: 401 }));
const client = createVaultClient({ authUrl: 'http://x', getToken: () => 't' });
const state = await client.unlock();
expect(state).toEqual({ status: 'error', reason: 'auth' });
});
it('reports auth error on 403', async () => {
mockFetch(() => new Response('', { status: 403 }));
const client = createVaultClient({ authUrl: 'http://x', getToken: () => 't' });
const state = await client.unlock();
expect(state).toEqual({ status: 'error', reason: 'auth' });
});
it('reports network error when fetch throws', async () => {
mockFetch(() => {
throw new Error('Failed to fetch');
});
const client = createVaultClient({ authUrl: 'http://x', getToken: () => 't' });
const state = await client.unlock();
expect(state).toEqual({ status: 'error', reason: 'network' });
});
it('reports auth error when no token is available', async () => {
mockFetch(() => new Response('', { status: 200 }));
const client = createVaultClient({ authUrl: 'http://x', getToken: () => null });
const state = await client.unlock();
expect(state).toEqual({ status: 'error', reason: 'auth' });
// No fetch call should have happened
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0);
});
});
describe('createVaultClient.lock', () => {
it('clears the active key', async () => {
const masterKey = await freshKeyBase64();
mockFetch(() => new Response(JSON.stringify({ masterKey }), { status: 200 }));
const client = createVaultClient({ authUrl: 'http://x', getToken: () => 't' });
await client.unlock();
expect(isVaultUnlocked()).toBe(true);
client.lock();
expect(isVaultUnlocked()).toBe(false);
expect(getActiveKey()).toBe(null);
});
});
describe('createVaultClient.refetch', () => {
it('clears and re-fetches the master key', async () => {
const k1 = await freshKeyBase64();
const k2 = await freshKeyBase64();
let call = 0;
mockFetch(() => {
const body = call++ === 0 ? k1 : k2;
return new Response(JSON.stringify({ masterKey: body }), { status: 200 });
});
const client = createVaultClient({ authUrl: 'http://x', getToken: () => 't' });
await client.unlock();
const before = getActiveKey();
await client.refetch();
const after = getActiveKey();
expect(before).not.toBe(null);
expect(after).not.toBe(null);
// Two underlying fetches happened
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2);
});
});
describe('createVaultClient.rotate', () => {
it('POSTs /rotate and installs the new key', async () => {
const masterKey = await freshKeyBase64();
const calls: { url: string; method?: string }[] = [];
mockFetch((url, init) => {
calls.push({ url, method: init?.method });
return new Response(JSON.stringify({ masterKey }), { status: 200 });
});
const client = createVaultClient({ authUrl: 'http://x', getToken: () => 't' });
const state = await client.rotate();
expect(state).toEqual({ status: 'unlocked' });
expect(calls[0].method).toBe('POST');
expect(calls[0].url).toContain('/rotate');
});
});

View file

@ -0,0 +1,258 @@
/**
* Browser-side client for the mana-auth encryption vault.
*
* Talks to the three endpoints introduced in Phase 2:
* POST /api/v1/me/encryption-vault/init bootstrap a fresh master key
* GET /api/v1/me/encryption-vault/key fetch the active master key
* POST /api/v1/me/encryption-vault/rotate destructive rotation
*
* Behaviour:
* - `unlock(token)` is the standard login path. Calls GET /key first; if
* the server returns 404 (`VAULT_NOT_INITIALISED`), automatically
* follows up with POST /init so the user is bootstrapped on first
* login per device. The recovered raw bytes are imported into a
* non-extractable CryptoKey and pushed into the active KeyProvider.
* - `lock()` clears the in-memory key. Called from the layout on logout.
* - All network calls go through a small retry loop (3 attempts,
* exponential backoff with jitter) so transient 5xx / network blips
* don't immediately leave the vault locked. 4xx is treated as
* permanent and surfaces immediately.
*
* Read-only mode on persistent failure:
* If the vault cannot be unlocked after retries, the active key
* provider stays locked. Sync-tracked tables that are flagged
* `enabled: true` in the registry will refuse to write (the
* wrapValue helper throws on a missing key) caller code surfaces
* that as a "vault locked" toast. Plaintext tables continue to work.
*
* NOT in this module:
* - Token acquisition. The caller passes the JWT in. The vault client
* stays decoupled from the auth store so it can be tested with a
* mock fetch.
* - The MemoryKeyProvider instance wired in by the layout/boot path
* via setKeyProvider(). This module just talks to the configured
* provider via setActiveProvider() at construction.
*/
import { importMasterKey } from './aes';
import { MemoryKeyProvider, setKeyProvider, getKeyProvider } from './key-provider';
const RETRY_MAX_ATTEMPTS = 3;
const RETRY_BASE_DELAY_MS = 500;
const RETRY_MAX_DELAY_MS = 8000;
/** HTTP status codes that warrant a retry — transient server / network. */
function isRetriableStatus(status: number): boolean {
return status === 0 || status === 408 || status === 429 || status >= 500;
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
function backoffDelay(attempt: number): number {
const exp = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** attempt);
return Math.floor(Math.random() * exp);
}
export type VaultUnlockState =
| { status: 'unlocked' }
| { status: 'locked' }
| { status: 'error'; reason: 'auth' | 'network' | 'server' | 'unknown' };
export interface VaultClientOptions {
/** Base URL of mana-auth, e.g. 'https://auth.mana.how'. */
authUrl: string;
/** Function returning the current JWT, or null if signed out. */
getToken: () => Promise<string | null> | string | null;
}
export interface VaultClient {
/** Unlocks the in-memory key provider by fetching from the server.
* On first call per device, automatically initialises the vault. */
unlock(): Promise<VaultUnlockState>;
/** Clears the in-memory key — call on logout. */
lock(): void;
/** Forces a fresh fetch even if the provider is already unlocked.
* Used by the rotate flow + tests. */
refetch(): Promise<VaultUnlockState>;
/** Triggers POST /rotate. Caller is responsible for re-encryption. */
rotate(): Promise<VaultUnlockState>;
/** Current snapshot of the unlock state. */
getState(): VaultUnlockState;
}
/**
* Builds a vault client and installs a MemoryKeyProvider as the active
* provider. Idempotent: calling twice returns a fresh client but reuses
* the same provider, so the new client picks up an already-unlocked key.
*/
export function createVaultClient(options: VaultClientOptions): VaultClient {
const { authUrl, getToken } = options;
// Reuse the existing MemoryKeyProvider if one is already installed —
// otherwise create + register a fresh one.
let provider: MemoryKeyProvider;
const existing = getKeyProvider();
if (existing instanceof MemoryKeyProvider) {
provider = existing;
} else {
provider = new MemoryKeyProvider();
setKeyProvider(provider);
}
let state: VaultUnlockState = provider.isUnlocked()
? { status: 'unlocked' }
: { status: 'locked' };
// ─── Internal: HTTP with retry ───────────────────────────
async function fetchVault(
path: string,
init: RequestInit
): Promise<
| { ok: true; data: { masterKey: string } }
| { ok: false; status: number; body?: { error?: string; code?: string } }
> {
let lastStatus = 0;
for (let attempt = 0; attempt < RETRY_MAX_ATTEMPTS; attempt++) {
let res: Response;
try {
res = await fetch(`${authUrl}${path}`, init);
} catch {
lastStatus = 0; // network error
if (attempt < RETRY_MAX_ATTEMPTS - 1) await sleep(backoffDelay(attempt));
continue;
}
if (res.ok) {
const data = (await res.json()) as { masterKey: string };
return { ok: true, data };
}
lastStatus = res.status;
if (!isRetriableStatus(res.status)) {
const body = await res.json().catch(() => undefined);
return { ok: false, status: res.status, body };
}
if (attempt < RETRY_MAX_ATTEMPTS - 1) await sleep(backoffDelay(attempt));
}
return { ok: false, status: lastStatus };
}
function authHeaders(token: string): RequestInit {
return {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
};
}
async function applyMasterKey(masterKeyB64: string): Promise<void> {
const raw = base64ToBytes(masterKeyB64);
const cryptoKey = await importMasterKey(raw);
provider.setKey(cryptoKey);
// Best-effort: zero the raw bytes once they're imported. Doesn't
// guarantee the JS heap is wiped (the underlying ArrayBuffer is
// owned by the GC), but at least our reference goes away.
raw.fill(0);
}
async function categorise(status: number): Promise<VaultUnlockState> {
if (status === 401 || status === 403) return { status: 'error', reason: 'auth' };
if (status === 0) return { status: 'error', reason: 'network' };
if (status >= 500) return { status: 'error', reason: 'server' };
return { status: 'error', reason: 'unknown' };
}
// ─── Public methods ──────────────────────────────────────
async function unlock(): Promise<VaultUnlockState> {
if (provider.isUnlocked()) {
state = { status: 'unlocked' };
return state;
}
const token = await getToken();
if (!token) {
state = { status: 'error', reason: 'auth' };
return state;
}
// Try GET /key first.
const fetchRes = await fetchVault('/api/v1/me/encryption-vault/key', {
method: 'GET',
...authHeaders(token),
});
if (fetchRes.ok) {
await applyMasterKey(fetchRes.data.masterKey);
state = { status: 'unlocked' };
return state;
}
// 404 with VAULT_NOT_INITIALISED → bootstrap by calling /init.
if (fetchRes.status === 404 && fetchRes.body?.code === 'VAULT_NOT_INITIALISED') {
const initRes = await fetchVault('/api/v1/me/encryption-vault/init', {
method: 'POST',
...authHeaders(token),
});
if (initRes.ok) {
await applyMasterKey(initRes.data.masterKey);
state = { status: 'unlocked' };
return state;
}
state = await categorise(initRes.status);
return state;
}
state = await categorise(fetchRes.status);
return state;
}
function lock(): void {
provider.setKey(null);
state = { status: 'locked' };
}
async function refetch(): Promise<VaultUnlockState> {
provider.setKey(null);
return unlock();
}
async function rotate(): Promise<VaultUnlockState> {
const token = await getToken();
if (!token) {
state = { status: 'error', reason: 'auth' };
return state;
}
const res = await fetchVault('/api/v1/me/encryption-vault/rotate', {
method: 'POST',
...authHeaders(token),
});
if (res.ok) {
await applyMasterKey(res.data.masterKey);
state = { status: 'unlocked' };
return state;
}
state = await categorise(res.status);
return state;
}
function getState(): VaultUnlockState {
// Reconcile in case the provider was locked from somewhere else.
if (!provider.isUnlocked() && state.status === 'unlocked') {
state = { status: 'locked' };
}
return state;
}
return { unlock, lock, refetch, rotate, getState };
}
// ─── Helpers ─────────────────────────────────────────────────
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}

View file

@ -8,6 +8,8 @@
import { setCurrentUserId } from '$lib/data/current-user'; import { setCurrentUserId } from '$lib/data/current-user';
import { migrateGuestDataToUser } from '$lib/data/guest-migration'; import { migrateGuestDataToUser } from '$lib/data/guest-migration';
import { installDataLayerListeners } from '$lib/data/data-layer-listeners'; import { installDataLayerListeners } from '$lib/data/data-layer-listeners';
import { createVaultClient, hasAnyEncryption } from '$lib/data/crypto';
import { getManaAuthUrl } from '$lib/api/config';
import SuggestionToast from '$lib/components/SuggestionToast.svelte'; import SuggestionToast from '$lib/components/SuggestionToast.svelte';
import OfflineIndicator from '$lib/components/OfflineIndicator.svelte'; import OfflineIndicator from '$lib/components/OfflineIndicator.svelte';
import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte'; import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte';
@ -19,6 +21,14 @@
// initialisation, which previously caused effect_update_depth_exceeded. // initialisation, which previously caused effect_update_depth_exceeded.
let lastUserId: string | null | undefined = undefined; let lastUserId: string | null | undefined = undefined;
// Vault client is constructed lazily on the first auth-state change so
// the import path stays free of side-effects during SSR. Reused across
// all subsequent unlock/lock calls.
const vaultClient = createVaultClient({
authUrl: getManaAuthUrl(),
getToken: () => authStore.getAccessToken(),
});
// Push the active user id into the data layer whenever auth state changes. // Push the active user id into the data layer whenever auth state changes.
// The Dexie creating-hook reads this to auto-stamp `userId` on every record, // The Dexie creating-hook reads this to auto-stamp `userId` on every record,
// so module stores never need to know who the current user is. // so module stores never need to know who the current user is.
@ -40,6 +50,19 @@
console.error('[mana] guest → user migration failed:', err); console.error('[mana] guest → user migration failed:', err);
}); });
} }
// Encryption vault: unlock when authenticated, lock when not.
// Skip the network round-trip entirely while no table is encrypted —
// hasAnyEncryption() flips to true once Phase 3 enables a pilot.
if (userId && hasAnyEncryption()) {
vaultClient.unlock().then((state) => {
if (state.status !== 'unlocked') {
console.warn('[mana] encryption vault unlock failed:', state);
}
});
} else if (!userId) {
vaultClient.lock();
}
}); });
onMount(() => { onMount(() => {