feat(auth): add per-account X25519 identity keypair (#3)
Some checks failed
Lint and Build / format (pull_request) Successful in 42s
Lint and Build / clippy (pull_request) Successful in 1m46s
Lint and Build / build (pull_request) Successful in 3m47s
Lint and Build / test (pull_request) Failing after 0s

Phase A1 of the multi-person sharing ADR
(docs/adr/multi-person-sharing.md). Each account now gets an X25519
keypair at registration: the public half stored plaintext, the private
half wrapped under the account DEK and stored as opaque ciphertext. The
keypair is generated client-side; the backend adds no crypto deps and
stores everything verbatim, preserving the zero-knowledge contract.

This change only introduces the keypair and threads it through the auth
flows — it is not consumed yet. It unblocks Phase B (profile sharing)
without touching the data model or the ~11 frontend encrypt call sites,
which is Phase A2 (per-profile DEKs).

Backend:
- User model: identity_public_key, identity_private_key_wrapped{,_iv}
  (all Option<String>, backward compatible).
- RegisterRequest/AuthResponse carry the 3 fields; register + login
  echo them. No changes to change_password/recover (DEK value is
  unchanged across both, so the wrapped private key is too).
- 2 new integration tests: round-trip through register/login, and
  optional-fields backward compat.

Frontend:
- crypto/keys.ts: generateIdentityKeyPair, wrapIdentityPrivateKey,
  unwrapIdentityPrivateKey + in-memory identity store mirroring the DEK.
- types/api.ts: AuthTokens + RegisterRequest extended (removes an
  existing `as any` cast).
- useStore register/login/logout + UnlockPage unwrap the private key
  alongside the DEK.
- 3 new crypto tests (X25519 lifecycle, wrong-DEK rejection, distinct
  shared secrets); skip gracefully where the runtime lacks X25519.

Backend: cargo test + clippy green. Frontend: npm test + tsc green.

Also gitignore .zcode/ (local tooling artifact).
This commit is contained in:
goose 2026-07-18 20:00:01 -03:00
parent 6a569da3b1
commit 8ee8012aca
10 changed files with 437 additions and 5 deletions

View file

@ -9,8 +9,30 @@ import {
setEncKey,
clearEncKey,
hasEncKey,
generateIdentityKeyPair,
wrapIdentityPrivateKey,
unwrapIdentityPrivateKey,
setIdentityPrivate,
clearIdentityPrivate,
hasIdentityPrivate,
} from './index';
/** X25519 in Web Crypto is a recent addition (Node 16+, modern browsers). Some
* test runtimes (older jsdom/Node) may not implement it; detect once and skip
* the identity-keypair tests rather than fail spuriously. */
async function x25519Supported(): Promise<boolean> {
try {
await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'X25519' },
true,
['deriveBits'],
);
return true;
} catch {
return false;
}
}
describe('zero-knowledge crypto lifecycle', () => {
const password = 'my-secret-password';
const recoveryPhrase = 'my-recovery-phrase';
@ -99,3 +121,106 @@ describe('zero-knowledge crypto lifecycle', () => {
await expect(decryptJson(encrypted, setupB.dek)).rejects.toThrow();
});
});
describe('account X25519 identity keypair (Phase A1)', () => {
const password = 'my-secret-password';
it('full lifecycle: generate → wrap → unlock DEK → unwrap → deriveBits', async () => {
if (!(await x25519Supported())) {
console.log('[crypto] skipped (X25519 unsupported in this runtime)');
return;
}
// 1. Register: set up the account DEK and generate the identity keypair.
const setup = await setupEncryption(password);
const { publicKey, privateKey } = await generateIdentityKeyPair();
expect(publicKey).not.toBe('');
setIdentityPrivate(privateKey);
expect(hasIdentityPrivate()).toBe(true);
// 2. Wrap the private key under the account DEK and "store" it.
const wrapped = await wrapIdentityPrivateKey(privateKey, setup.dek);
expect(wrapped.data).not.toBe('');
// 3. Simulate page reload: both keys are lost from memory.
clearIdentityPrivate();
expect(hasIdentityPrivate()).toBe(false);
// 4. Unlock the DEK with the password (unlock screen flow).
const { dek } = await unlockWithPassword(password, setup.passwordWrappedDek);
// 5. Unwrap the identity private key from the stored wrapped form.
const recovered = await unwrapIdentityPrivateKey(wrapped, dek);
setIdentityPrivate(recovered);
expect(hasIdentityPrivate()).toBe(true);
// 6. The recovered private key is a USABLE X25519 key: it can derive a
// shared secret against the original public key. Import the public key
// and ECDH-derive — this proves the round-trip preserved a working key.
const pubBytes = Uint8Array.from(atob(publicKey), (c) => c.charCodeAt(0));
const pubKey = await crypto.subtle.importKey(
'raw',
pubBytes as BufferSource,
{ name: 'ECDH', namedCurve: 'X25519' },
false,
[],
);
const sharedSecret = await crypto.subtle.deriveBits(
{ name: 'ECDH', public: pubKey },
recovered,
256,
);
expect(sharedSecret.byteLength).toBe(32);
clearIdentityPrivate();
});
it('a wrapped private key cannot be unwrapped with the wrong DEK', async () => {
if (!(await x25519Supported())) {
console.log('[crypto] skipped (X25519 unsupported in this runtime)');
return;
}
// DEK for account A; a different DEK for account B.
const setupA = await setupEncryption('user-a-password');
const setupB = await setupEncryption('user-b-password');
const { privateKey } = await generateIdentityKeyPair();
const wrapped = await wrapIdentityPrivateKey(privateKey, setupA.dek);
// User B's DEK cannot unwrap user A's identity private key.
await expect(unwrapIdentityPrivateKey(wrapped, setupB.dek)).rejects.toThrow();
});
it('two identity keypairs derive different shared secrets with the same peer', async () => {
if (!(await x25519Supported())) {
console.log('[crypto] skipped (X25519 unsupported in this runtime)');
return;
}
// Sanity: distinct keypairs must produce distinct ECDH outputs.
const peer = await generateIdentityKeyPair();
const peerPubBytes = Uint8Array.from(atob(peer.publicKey), (c) => c.charCodeAt(0));
const peerPub = await crypto.subtle.importKey(
'raw',
peerPubBytes as BufferSource,
{ name: 'ECDH', namedCurve: 'X25519' },
false,
[],
);
const a = await generateIdentityKeyPair();
const b = await generateIdentityKeyPair();
const secretA = await crypto.subtle.deriveBits(
{ name: 'ECDH', public: peerPub },
a.privateKey,
256,
);
const secretB = await crypto.subtle.deriveBits(
{ name: 'ECDH', public: peerPub },
b.privateKey,
256,
);
const aHex = Array.from(new Uint8Array(secretA)).map((x) => x.toString(16)).join('');
const bHex = Array.from(new Uint8Array(secretB)).map((x) => x.toString(16)).join('');
expect(aHex).not.toBe(bHex);
});
});