From 8ee8012acac2ad38af128890bfc89edfe52175c5 Mon Sep 17 00:00:00 2001 From: goose Date: Sat, 18 Jul 2026 20:00:01 -0300 Subject: [PATCH] feat(auth): add per-account X25519 identity keypair (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 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). --- .gitignore | 1 + backend/src/handlers/auth.rs | 22 +++ backend/src/models/user.rs | 18 +++ backend/tests/auth_tests.rs | 74 +++++++++++ .../src/crypto/crypto.e2e.test.ts | 125 ++++++++++++++++++ web/normogen-web/src/crypto/index.ts | 7 + web/normogen-web/src/crypto/keys.ts | 80 +++++++++++ web/normogen-web/src/pages/UnlockPage.tsx | 39 +++++- web/normogen-web/src/store/useStore.ts | 59 ++++++++- web/normogen-web/src/types/api.ts | 17 +++ 10 files changed, 437 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index bdfa7c1..6098d17 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .env.local .env.*.local .forgejo-token +.zcode/ node_modules/ dist/ target/ diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index cbe8836..9935fd5 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -27,6 +27,11 @@ pub struct RegisterRequest { /// Wrapped DEK (base64 ciphertext) — encrypted under the recovery KEK pub recovery_wrapped_dek: Option, pub recovery_wrapped_dek_iv: Option, + /// Account X25519 identity public key (base64 raw). Plaintext. + pub identity_public_key: Option, + /// Account X25519 identity private key, wrapped under the account DEK. + pub identity_private_key_wrapped: Option, + pub identity_private_key_wrapped_iv: Option, } #[derive(Debug, Serialize)] @@ -40,6 +45,12 @@ pub struct AuthResponse { /// user has no wrapped DEK (legacy/pre-recovery accounts). pub wrapped_dek: Option, pub wrapped_dek_iv: Option, + /// Account X25519 identity keypair. The public key is plaintext; the + /// private key is an opaque ciphertext blob wrapped under the account DEK. + /// Absent for accounts that predate the identity-keypair feature. + pub identity_public_key: Option, + pub identity_private_key_wrapped: Option, + pub identity_private_key_wrapped_iv: Option, } pub async fn register( @@ -108,6 +119,11 @@ pub async fn register( user.recovery_wrapped_dek = req.recovery_wrapped_dek.clone(); user.recovery_wrapped_dek_iv = req.recovery_wrapped_dek_iv.clone(); user.recovery_enabled = req.recovery_wrapped_dek.is_some(); + // Store the X25519 identity keypair (public plaintext, private wrapped + // under the account DEK — server stores verbatim, cannot decrypt). + user.identity_public_key = req.identity_public_key; + user.identity_private_key_wrapped = req.identity_private_key_wrapped; + user.identity_private_key_wrapped_iv = req.identity_private_key_wrapped_iv; // Get token_version before saving let token_version = user.token_version; @@ -203,6 +219,9 @@ pub async fn register( username: user.username.clone(), wrapped_dek: user.wrapped_dek.clone(), wrapped_dek_iv: user.wrapped_dek_iv.clone(), + identity_public_key: user.identity_public_key.clone(), + identity_private_key_wrapped: user.identity_private_key_wrapped.clone(), + identity_private_key_wrapped_iv: user.identity_private_key_wrapped_iv.clone(), }; (StatusCode::CREATED, Json(response)).into_response() @@ -413,6 +432,9 @@ pub async fn login( username: user.username.clone(), wrapped_dek: user.wrapped_dek.clone(), wrapped_dek_iv: user.wrapped_dek_iv.clone(), + identity_public_key: user.identity_public_key.clone(), + identity_private_key_wrapped: user.identity_private_key_wrapped.clone(), + identity_private_key_wrapped_iv: user.identity_private_key_wrapped_iv.clone(), }; (StatusCode::OK, Json(response)).into_response() diff --git a/backend/src/models/user.rs b/backend/src/models/user.rs index 74de914..bbb0eb0 100644 --- a/backend/src/models/user.rs +++ b/backend/src/models/user.rs @@ -37,6 +37,21 @@ pub struct User { #[serde(skip_serializing_if = "Option::is_none")] pub recovery_wrapped_dek_iv: Option, + /// Account X25519 identity public key (base64 raw). Plaintext — public keys + /// are not secret. Other accounts wrap profile DEKs to this key (Phase B, + /// see docs/adr/multi-person-sharing.md). Generated client-side at + /// registration. + #[serde(skip_serializing_if = "Option::is_none")] + pub identity_public_key: Option, + + /// Account X25519 identity private key, wrapped (AES-256-GCM encrypted) + /// under the account DEK. The server stores this verbatim and cannot + /// decrypt it. The client unwraps it at login after unwrapping the DEK. + #[serde(skip_serializing_if = "Option::is_none")] + pub identity_private_key_wrapped: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub identity_private_key_wrapped_iv: Option, + /// Token version for invalidating all tokens on password change pub token_version: i32, @@ -93,6 +108,9 @@ impl User { wrapped_dek_iv: None, recovery_wrapped_dek: None, recovery_wrapped_dek_iv: None, + identity_public_key: None, + identity_private_key_wrapped: None, + identity_private_key_wrapped_iv: None, token_version: 0, created_at: now, last_active: now, diff --git a/backend/tests/auth_tests.rs b/backend/tests/auth_tests.rs index 3da535a..ce7b24f 100644 --- a/backend/tests/auth_tests.rs +++ b/backend/tests/auth_tests.rs @@ -287,6 +287,80 @@ async fn password_change_invalidates_existing_tokens() { common::drop_test_db(&db_name).await; } +#[tokio::test] +async fn identity_keypair_round_trips_through_register_and_login() { + // Phase A1 (#3): the X25519 identity keypair fields (public plaintext, + // private wrapped under the account DEK) are stored verbatim at register + // and echoed back on login. The server never inspects or derives them. + let (app, db_name) = require_app!(common::app_for_test().await); + let email = unique_email(); + + // Opaque blobs — the server treats these as base64 strings, nothing more. + let public_key = "base64-x25519-public-key-for-test"; + let priv_wrapped = "base64-wrapped-priv-ciphertext"; + let priv_wrapped_iv = "base64-12-byte-iv"; + + let (status, body) = common::send_json( + &app, + "POST", + "/api/auth/register", + Some(json!({ + "email": email, + "username": "tester", + "password": "supersecret", + "identity_public_key": public_key, + "identity_private_key_wrapped": priv_wrapped, + "identity_private_key_wrapped_iv": priv_wrapped_iv, + })), + None, + ) + .await; + assert_eq!(status, 201, "register should return 201, body: {body}"); + assert_eq!(body["identity_public_key"], public_key); + assert_eq!(body["identity_private_key_wrapped"], priv_wrapped); + assert_eq!(body["identity_private_key_wrapped_iv"], priv_wrapped_iv); + + // Login must echo the same stored values. + let (status, body) = common::send_json( + &app, + "POST", + "/api/auth/login", + Some(json!({ "email": email, "password": "supersecret" })), + None, + ) + .await; + assert_eq!(status, 200, "login should return 200, body: {body}"); + assert_eq!(body["identity_public_key"], public_key); + assert_eq!(body["identity_private_key_wrapped"], priv_wrapped); + assert_eq!(body["identity_private_key_wrapped_iv"], priv_wrapped_iv); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn register_without_identity_keypair_stays_optional() { + // Backward compat: existing clients that don't send identity fields must + // still register successfully, and the fields are absent from the response. + let (app, db_name) = require_app!(common::app_for_test().await); + let email = unique_email(); + + let (status, body) = common::send_json( + &app, + "POST", + "/api/auth/register", + Some(json!({ "email": email, "username": "tester", "password": "supersecret" })), + None, + ) + .await; + assert_eq!(status, 201, "register should return 201, body: {body}"); + assert!( + body.get("identity_public_key").is_none() || body["identity_public_key"].is_null(), + "identity_public_key should be absent when not provided: {body}" + ); + + common::drop_test_db(&db_name).await; +} + // ---- helpers ---- fn unique_email() -> String { diff --git a/web/normogen-web/src/crypto/crypto.e2e.test.ts b/web/normogen-web/src/crypto/crypto.e2e.test.ts index b160193..ff6c3e7 100644 --- a/web/normogen-web/src/crypto/crypto.e2e.test.ts +++ b/web/normogen-web/src/crypto/crypto.e2e.test.ts @@ -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 { + 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); + }); +}); diff --git a/web/normogen-web/src/crypto/index.ts b/web/normogen-web/src/crypto/index.ts index 16ec69f..afdf0aa 100644 --- a/web/normogen-web/src/crypto/index.ts +++ b/web/normogen-web/src/crypto/index.ts @@ -8,10 +8,17 @@ export { setupEncryption, unlockWithPassword, unlockWithRecovery, + generateIdentityKeyPair, + wrapIdentityPrivateKey, + unwrapIdentityPrivateKey, setEncKey, getEncKey, clearEncKey, hasEncKey, + setIdentityPrivate, + getIdentityPrivate, + clearIdentityPrivate, + hasIdentityPrivate, AUTH_SALT, PASSWORD_KEK_SALT, RECOVERY_KEK_SALT, diff --git a/web/normogen-web/src/crypto/keys.ts b/web/normogen-web/src/crypto/keys.ts index 25577a5..69d49fb 100644 --- a/web/normogen-web/src/crypto/keys.ts +++ b/web/normogen-web/src/crypto/keys.ts @@ -185,6 +185,62 @@ export async function unlockWithRecovery( return unwrapDek(recoveryWrappedDek, recoveryKek); } +// --------------------------------------------------------------------------- +// Account X25519 identity keypair (Phase A1, multi-person sharing prep). +// +// Each account gets an X25519 keypair at registration. The PUBLIC half is +// stored in plaintext (public keys are not secret); other accounts will wrap +// profile DEKs to it in Phase B. The PRIVATE half is wrapped (AES-GCM +// encrypted) under the account DEK and stored on the server as opaque +// ciphertext. The server never sees the private key. +// +// See docs/adr/multi-person-sharing.md §1. +// --------------------------------------------------------------------------- + +/** Generate an X25519 keypair for the account. Returns the public key as a + * base64 raw string (for the server to store) and the private key as a + * CryptoKey (kept in memory, wrapped before upload). */ +export async function generateIdentityKeyPair(): Promise<{ + publicKey: string; + privateKey: CryptoKey; +}> { + const { publicKey, privateKey } = (await crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'X25519' }, + true, // extractable so we can export the private key for wrapping + ['deriveBits'], + )) as CryptoKeyPair; + const rawPublic = await crypto.subtle.exportKey('raw', publicKey); + return { publicKey: base64(new Uint8Array(rawPublic)), privateKey }; +} + +/** Wrap (encrypt) the X25519 private key under the account DEK. The DEK is an + * AES-GCM key, so we reuse the string-cipher from cipher.ts: export the private + * key to raw bytes, base64-encode, and encrypt. */ +export async function wrapIdentityPrivateKey( + privateKey: CryptoKey, + dek: CryptoKey, +): Promise { + const rawPrivate = await crypto.subtle.exportKey('raw', privateKey); + return encrypt(base64(new Uint8Array(rawPrivate)), dek); +} + +/** Unwrap (decrypt) the X25519 private key from its DEK-wrapped form. Inverse + * of wrapIdentityPrivateKey. The result is importable for ECDH deriveBits. */ +export async function unwrapIdentityPrivateKey( + payload: CipherPayload, + dek: CryptoKey, +): Promise { + const rawPrivateB64 = await decrypt(payload, dek); + const rawPrivate = Uint8Array.from(atob(rawPrivateB64), (c) => c.charCodeAt(0)); + return crypto.subtle.importKey( + 'raw', + rawPrivate as BufferSource, + { name: 'ECDH', namedCurve: 'X25519' }, + true, // extractable so it can be re-wrapped if ever needed + ['deriveBits'], + ); +} + // --------------------------------------------------------------------------- // In-memory key store (unchanged from Phase 1) // --------------------------------------------------------------------------- @@ -207,6 +263,30 @@ export function hasEncKey(): boolean { return currentEncKey !== null; } +// --------------------------------------------------------------------------- +// In-memory identity-private-key store (Phase A1). Same lifecycle as the DEK: +// held only for the authenticated session, cleared on logout, re-derived from +// the wrapped form (under the account DEK) on unlock. Not consumed until Phase B. +// --------------------------------------------------------------------------- + +let currentIdentityPrivate: CryptoKey | null = null; + +export function setIdentityPrivate(key: CryptoKey): void { + currentIdentityPrivate = key; +} + +export function getIdentityPrivate(): CryptoKey | null { + return currentIdentityPrivate; +} + +export function clearIdentityPrivate(): void { + currentIdentityPrivate = null; +} + +export function hasIdentityPrivate(): boolean { + return currentIdentityPrivate !== null; +} + // --------------------------------------------------------------------------- // Backward compat: the old deriveAuthAndEncKeys (Phase 1 direct-from-password // enc key). Kept for tests; new code uses setupEncryption/unlockWithPassword. diff --git a/web/normogen-web/src/pages/UnlockPage.tsx b/web/normogen-web/src/pages/UnlockPage.tsx index 2752138..8e3b0a3 100644 --- a/web/normogen-web/src/pages/UnlockPage.tsx +++ b/web/normogen-web/src/pages/UnlockPage.tsx @@ -12,11 +12,23 @@ import { } from '@mui/material'; import { Lock as LockIcon } from '@mui/icons-material'; import { useAuthStore } from '../store/useStore'; -import { unlockWithPassword, deriveAuthAndEncKeys, setEncKey } from '../crypto'; +import { + unlockWithPassword, + deriveAuthAndEncKeys, + unwrapIdentityPrivateKey, + setEncKey, + setIdentityPrivate, +} from '../crypto'; export const UnlockPage: FC = () => { const navigate = useNavigate(); - const { wrapped_dek, wrapped_dek_iv, user } = useAuthStore(); + const { + wrapped_dek, + wrapped_dek_iv, + identity_private_key_wrapped, + identity_private_key_wrapped_iv, + user, + } = useAuthStore(); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [unlocking, setUnlocking] = useState(false); @@ -27,13 +39,32 @@ export const UnlockPage: FC = () => { setUnlocking(true); try { + let dek; if (wrapped_dek && wrapped_dek_iv) { // Wrapped-DEK model: unwrap the DEK using the password. - const { dek } = await unlockWithPassword(password, { + ({ dek } = await unlockWithPassword(password, { data: wrapped_dek, iv: wrapped_dek_iv, - }); + })); setEncKey(dek); + // Phase A1: with the DEK available, also unwrap the X25519 identity + // private key into in-memory storage. Non-fatal if it fails (e.g. + // legacy accounts without an identity keypair). + if (identity_private_key_wrapped && identity_private_key_wrapped_iv) { + try { + const identityPrivate = await unwrapIdentityPrivateKey( + { + data: identity_private_key_wrapped, + iv: identity_private_key_wrapped_iv, + }, + dek, + ); + setIdentityPrivate(identityPrivate); + } catch { + // Wrapped private key present but undecryptable — ignore; Phase B + // features will surface a re-setup need. + } + } } else { // Phase 1 compat: derive the key directly from the password. const { encKey } = await deriveAuthAndEncKeys(password); diff --git a/web/normogen-web/src/store/useStore.ts b/web/normogen-web/src/store/useStore.ts index 69211ee..7b96b9f 100644 --- a/web/normogen-web/src/store/useStore.ts +++ b/web/normogen-web/src/store/useStore.ts @@ -6,8 +6,13 @@ import { unlockWithPassword, unlockWithRecovery, rewrapDek, + generateIdentityKeyPair, + wrapIdentityPrivateKey, + unwrapIdentityPrivateKey, setEncKey, clearEncKey, + clearIdentityPrivate, + setIdentityPrivate, getEncKey, encryptJson, decryptJson, @@ -38,6 +43,12 @@ interface AuthState { // on page reload without a full re-login. wrapped_dek: string | null; wrapped_dek_iv: string | null; + // Account X25519 identity keypair (Phase A1). The public key is plaintext; + // the private key is the DEK-wrapped ciphertext blob. Persisted for the same + // reason as the wrapped DEK — the unlock screen unwraps it into memory. + identity_public_key: string | null; + identity_private_key_wrapped: string | null; + identity_private_key_wrapped_iv: string | null; // Actions login: (email: string, password: string) => Promise; @@ -128,6 +139,9 @@ export const useAuthStore = create()( error: null, wrapped_dek: null, wrapped_dek_iv: null, + identity_public_key: null, + identity_private_key_wrapped: null, + identity_private_key_wrapped_iv: null, login: async (email: string, password: string) => { set({ isLoading: true, error: null }); @@ -144,6 +158,26 @@ export const useAuthStore = create()( iv: response.wrapped_dek_iv, }); setEncKey(dek); + // Once the DEK is available, also unwrap the X25519 identity + // private key (Phase A1) into in-memory storage. + if ( + response.identity_private_key_wrapped && + response.identity_private_key_wrapped_iv + ) { + try { + const identityPrivate = await unwrapIdentityPrivateKey( + { + data: response.identity_private_key_wrapped, + iv: response.identity_private_key_wrapped_iv, + }, + dek, + ); + setIdentityPrivate(identityPrivate); + } catch { + // Wrapped private key present but failed to unwrap — + // non-fatal; Phase B features will surface a re-setup need. + } + } } catch { // Fallback to the PBKDF2-derived key (Phase 1 compat). } @@ -160,6 +194,9 @@ export const useAuthStore = create()( isLoading: false, wrapped_dek: response.wrapped_dek ?? null, wrapped_dek_iv: response.wrapped_dek_iv ?? null, + identity_public_key: response.identity_public_key ?? null, + identity_private_key_wrapped: response.identity_private_key_wrapped ?? null, + identity_private_key_wrapped_iv: response.identity_private_key_wrapped_iv ?? null, }); } catch (error: any) { clearEncKey(); @@ -181,6 +218,13 @@ export const useAuthStore = create()( const setup = await setupEncryption(password, recoveryPhrase); setEncKey(setup.dek); + // Phase A1: generate the account X25519 identity keypair and wrap + // the private half under the account DEK. The public key is sent + // plaintext; the wrapped private key is opaque ciphertext. + const { publicKey, privateKey } = await generateIdentityKeyPair(); + const wrappedPrivate = await wrapIdentityPrivateKey(privateKey, setup.dek); + setIdentityPrivate(privateKey); + const response = await apiService.register({ username, email, @@ -190,7 +234,10 @@ export const useAuthStore = create()( recovery_wrapped_dek: setup.recoveryWrappedDek?.data, recovery_wrapped_dek_iv: setup.recoveryWrappedDek?.iv, recovery_phrase: setup.recoveryKekHash, - } as any); + identity_public_key: publicKey, + identity_private_key_wrapped: wrappedPrivate.data, + identity_private_key_wrapped_iv: wrappedPrivate.iv, + }); set({ user: { user_id: response.user_id, @@ -203,6 +250,9 @@ export const useAuthStore = create()( isLoading: false, wrapped_dek: setup.passwordWrappedDek.data, wrapped_dek_iv: setup.passwordWrappedDek.iv, + identity_public_key: publicKey, + identity_private_key_wrapped: wrappedPrivate.data, + identity_private_key_wrapped_iv: wrappedPrivate.iv, }); } catch (error: any) { set({ @@ -254,6 +304,7 @@ export const useAuthStore = create()( logout: async () => { clearEncKey(); + clearIdentityPrivate(); await apiService.logout(); set({ user: null, @@ -262,6 +313,9 @@ export const useAuthStore = create()( error: null, wrapped_dek: null, wrapped_dek_iv: null, + identity_public_key: null, + identity_private_key_wrapped: null, + identity_private_key_wrapped_iv: null, }); }, @@ -296,6 +350,9 @@ export const useAuthStore = create()( isAuthenticated: state.isAuthenticated, wrapped_dek: state.wrapped_dek, wrapped_dek_iv: state.wrapped_dek_iv, + identity_public_key: state.identity_public_key, + identity_private_key_wrapped: state.identity_private_key_wrapped, + identity_private_key_wrapped_iv: state.identity_private_key_wrapped_iv, }), } ) diff --git a/web/normogen-web/src/types/api.ts b/web/normogen-web/src/types/api.ts index 67ccf49..9c09d34 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -42,6 +42,11 @@ export interface AuthTokens { email?: string; wrapped_dek?: string; wrapped_dek_iv?: string; + /** Account X25519 identity keypair (Phase A1). Public key is plaintext; + * private key is wrapped under the account DEK. Absent for legacy accounts. */ + identity_public_key?: string; + identity_private_key_wrapped?: string; + identity_private_key_wrapped_iv?: string; } export interface LoginRequest { @@ -54,6 +59,18 @@ export interface RegisterRequest { email: string; password: string; role?: 'patient' | 'provider' | 'admin'; + /** Zero-knowledge wrapped DEK (password KEK). */ + wrapped_dek?: string; + wrapped_dek_iv?: string; + /** Zero-knowledge wrapped DEK (recovery KEK). */ + recovery_wrapped_dek?: string; + recovery_wrapped_dek_iv?: string; + /** Recovery proof (PBKDF2 derivation of the recovery phrase). */ + recovery_phrase?: string; + /** Account X25519 identity keypair (Phase A1). */ + identity_public_key?: string; + identity_private_key_wrapped?: string; + identity_private_key_wrapped_iv?: string; } // Medication Types -- 2.47.3