diff --git a/broadcast-worker/handler.go b/broadcast-worker/handler.go index a1f199c83..ec62f8148 100644 --- a/broadcast-worker/handler.go +++ b/broadcast-worker/handler.go @@ -39,10 +39,18 @@ type Handler struct { pub Publisher keyStore RoomKeyProvider encrypt bool + encoder *roomcrypto.Encoder } func NewHandler(store Store, userStore userstore.UserStore, pub Publisher, keyStore RoomKeyProvider, encrypt bool) *Handler { - return &Handler{store: store, userStore: userStore, pub: pub, keyStore: keyStore, encrypt: encrypt} + return &Handler{ + store: store, + userStore: userStore, + pub: pub, + keyStore: keyStore, + encrypt: encrypt, + encoder: roomcrypto.NewEncoder(), + } } // HandleMessage processes a single MESSAGES_CANONICAL message payload. @@ -209,7 +217,7 @@ func (h *Handler) encryptEditedContent(ctx context.Context, roomID string, edite if err != nil { return err } - encrypted, err := roomcrypto.Encode(edited.NewContent, key.KeyPair.PublicKey, key.Version) + encrypted, err := h.encoder.Encode(roomID, edited.NewContent, key.KeyPair.PrivateKey, key.Version) if err != nil { return fmt.Errorf("encrypt edit content for room %s: %w", roomID, err) } @@ -253,7 +261,7 @@ func (h *Handler) publishChannelEvent(ctx context.Context, meta roommetacache.Me return err } - encrypted, err := roomcrypto.Encode(string(msgJSON), key.KeyPair.PublicKey, key.Version) + encrypted, err := h.encoder.Encode(meta.ID, string(msgJSON), key.KeyPair.PrivateKey, key.Version) if err != nil { return fmt.Errorf("encrypt message for room %s: %w", meta.ID, err) } diff --git a/broadcast-worker/handler_test.go b/broadcast-worker/handler_test.go index aa9ea8b8e..1ac9cff63 100644 --- a/broadcast-worker/handler_test.go +++ b/broadcast-worker/handler_test.go @@ -658,7 +658,6 @@ func TestHandler_HandleMessage_ChannelRoom_Encryption(t *testing.T) { var env roomcrypto.EncryptedMessage require.NoError(t, json.Unmarshal(evt.EncryptedMessage, &env)) assert.Equal(t, key.Version, env.Version) - assert.NotEmpty(t, env.EphemeralPublicKey) assert.NotEmpty(t, env.Nonce) assert.NotEmpty(t, env.Ciphertext) diff --git a/broadcast-worker/testhelpers_test.go b/broadcast-worker/testhelpers_test.go index ef8b8caee..d05b1d8c3 100644 --- a/broadcast-worker/testhelpers_test.go +++ b/broadcast-worker/testhelpers_test.go @@ -3,16 +3,12 @@ package main import ( "crypto/aes" "crypto/cipher" - "crypto/ecdh" "crypto/rand" - "crypto/sha256" "encoding/json" "fmt" - "io" "testing" "github.com/stretchr/testify/require" - "golang.org/x/crypto/hkdf" "github.com/hmchangw/chat/pkg/model" "github.com/hmchangw/chat/pkg/roomcrypto" @@ -21,36 +17,20 @@ import ( func testRoomKey(t *testing.T) *roomkeystore.VersionedKeyPair { t.Helper() - priv, err := ecdh.P256().GenerateKey(rand.Reader) + buf := make([]byte, 32) + _, err := rand.Read(buf) require.NoError(t, err) return &roomkeystore.VersionedKeyPair{ Version: 3, KeyPair: roomkeystore.RoomKeyPair{ - PublicKey: priv.PublicKey().Bytes(), - PrivateKey: priv.Bytes(), + PrivateKey: buf, }, } } func decryptForTest(env *roomcrypto.EncryptedMessage, roomPrivateKey []byte) (string, error) { - privKey, err := ecdh.P256().NewPrivateKey(roomPrivateKey) - if err != nil { - return "", fmt.Errorf("parse room private key: %w", err) - } - ephPubKey, err := ecdh.P256().NewPublicKey(env.EphemeralPublicKey) - if err != nil { - return "", fmt.Errorf("parse ephemeral public key: %w", err) - } - sharedSecret, err := privKey.ECDH(ephPubKey) - if err != nil { - return "", fmt.Errorf("ecdh: %w", err) - } - aesKey := make([]byte, 32) - hkdfReader := hkdf.New(sha256.New, sharedSecret, nil, []byte("room-message-encryption")) - if _, err := io.ReadFull(hkdfReader, aesKey); err != nil { - return "", fmt.Errorf("hkdf: %w", err) - } - block, err := aes.NewCipher(aesKey) + // The room private key is used directly as the AES-256-GCM key (no HKDF step). + block, err := aes.NewCipher(roomPrivateKey) if err != nil { return "", fmt.Errorf("aes cipher: %w", err) } diff --git a/chat-frontend/scripts/gen-crypto-fixtures.go b/chat-frontend/scripts/gen-crypto-fixtures.go new file mode 100644 index 000000000..f73b09ff8 --- /dev/null +++ b/chat-frontend/scripts/gen-crypto-fixtures.go @@ -0,0 +1,67 @@ +//go:build ignore + +// gen-crypto-fixtures generates a known-plaintext encrypted message for +// chat-frontend's lib/roomcrypto round-trip tests. Run with: +// +// go run chat-frontend/scripts/gen-crypto-fixtures.go > chat-frontend/test/fixtures/encrypted-message.json +// +// Commit the output. The fixture exists to lock the cross-language wire +// format: any change in the server encoder's AES-GCM parameters or wire +// shape MUST update this fixture together with the corresponding TS +// decoder. +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + + "github.com/hmchangw/chat/pkg/roomcrypto" +) + +func main() { + // Deterministic private key so the fixture is stable across runs. + priv := make([]byte, 32) + for i := range priv { + priv[i] = byte(i + 1) + } + const plaintext = "fixture plaintext — encoded by the Go server, decoded by chat-frontend" + const version = 1 + const roomID = "fixture-room" + + // Construct an Encoder with a fixed-nonce reader so the ciphertext is + // reproducible. Twelve-byte zero nonce is fine for a fixture (it's a + // known-key test, not real traffic). + enc := roomcrypto.NewEncoder(roomcrypto.WithRand(zeroReader{})) + msg, err := enc.Encode(roomID, plaintext, priv, version) + if err != nil { + fmt.Fprintln(os.Stderr, "encode:", err) + os.Exit(1) + } + + out := struct { + PrivateKey string `json:"privateKey"` + Plaintext string `json:"plaintext"` + Message *roomcrypto.EncryptedMessage `json:"message"` + }{ + PrivateKey: base64.StdEncoding.EncodeToString(priv), + Plaintext: plaintext, + Message: msg, + } + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, "marshal:", err) + os.Exit(1) + } + fmt.Println(string(data)) +} + +type zeroReader struct{} + +func (zeroReader) Read(p []byte) (int, error) { + for i := range p { + p[i] = 0 + } + return len(p), nil +} diff --git a/chat-frontend/src/App.jsx b/chat-frontend/src/App.jsx index 38e1f661f..468c052c8 100644 --- a/chat-frontend/src/App.jsx +++ b/chat-frontend/src/App.jsx @@ -1,5 +1,6 @@ import { useEffect, useState, useCallback } from 'react' import { NatsProvider, useNats } from '@/context/NatsContext' +import { RoomKeysProvider } from '@/context/RoomKeysContext' import { RoomEventsProvider } from '@/context/RoomEventsContext' import { ThreadEventsProvider } from '@/context/ThreadEventsContext' import LoginPage from '@/pages/LoginPage' @@ -35,11 +36,13 @@ function AppContent() { } return ( - - - - - + + + + + + + ) } diff --git a/chat-frontend/src/api/_transport/subjects.test.js b/chat-frontend/src/api/_transport/subjects.test.js index 1424f2c07..ebc7b71d4 100644 --- a/chat-frontend/src/api/_transport/subjects.test.js +++ b/chat-frontend/src/api/_transport/subjects.test.js @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import { userRoomEvent, + userRoomKey, roomEvent, memberAdd, memberRemove, @@ -135,5 +136,11 @@ describe('subjects', () => { 'chat.user.alice.request.user.site-A.subscription.count' ) }) +}) +describe('userRoomKey', () => { + it('builds the per-user room-key event subject', () => { + expect(userRoomKey('alice')).toBe('chat.user.alice.event.room.key') + }) }) + diff --git a/chat-frontend/src/api/_transport/subjects.ts b/chat-frontend/src/api/_transport/subjects.ts index 766af6400..6c44cbce0 100644 --- a/chat-frontend/src/api/_transport/subjects.ts +++ b/chat-frontend/src/api/_transport/subjects.ts @@ -56,6 +56,10 @@ export function userRoomEvent(account: string): string { return `chat.user.${account}.event.room` } +export function userRoomKey(account: string): string { + return `chat.user.${account}.event.room.key` +} + export function memberAdd(account: string, roomId: string, siteId: string): string { return `chat.user.${account}.request.room.${roomId}.${siteId}.member.add` } diff --git a/chat-frontend/src/api/index.ts b/chat-frontend/src/api/index.ts index 54dbc8861..e97dadfeb 100644 --- a/chat-frontend/src/api/index.ts +++ b/chat-frontend/src/api/index.ts @@ -32,6 +32,7 @@ export { searchRooms } from './searchRooms' export { sendMessage } from './sendMessage' export { subscribeToRoomEvents } from './subscribeToRoomEvents' export { subscribeToRoomMetadataUpdates } from './subscribeToRoomMetadataUpdates' +export { subscribeToRoomKeyEvents } from './subscribeToRoomKeyEvents' export { subscribeToSubscriptionUpdates } from './subscribeToSubscriptionUpdates' export { subscribeToUserRoomEvents } from './subscribeToUserRoomEvents' export { updateMemberRole } from './updateMemberRole' @@ -72,4 +73,5 @@ export type { ChannelRef, HistoryConfig, HistoryMode, + RoomKeyEvent, } from './types' diff --git a/chat-frontend/src/api/subscribeToRoomKeyEvents/index.test.ts b/chat-frontend/src/api/subscribeToRoomKeyEvents/index.test.ts new file mode 100644 index 000000000..9588c9ad6 --- /dev/null +++ b/chat-frontend/src/api/subscribeToRoomKeyEvents/index.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect, vi } from 'vitest' +import { subscribeToRoomKeyEvents } from './index' + +describe('subscribeToRoomKeyEvents', () => { + it('subscribes to chat.user.{account}.event.room.key with the given callback', () => { + const subscribe = vi.fn().mockReturnValue({ unsubscribe: vi.fn() }) + const nats = { subscribe, user: { account: 'alice', id: '' }, request: vi.fn(), publish: vi.fn(), requestWithAsyncResult: vi.fn(), connected: true, error: null } + const cb = vi.fn() + + const sub = subscribeToRoomKeyEvents(nats as never, cb) + + expect(subscribe).toHaveBeenCalledWith('chat.user.alice.event.room.key', cb) + expect(sub).toBeDefined() + }) +}) diff --git a/chat-frontend/src/api/subscribeToRoomKeyEvents/index.ts b/chat-frontend/src/api/subscribeToRoomKeyEvents/index.ts new file mode 100644 index 000000000..22cd40658 --- /dev/null +++ b/chat-frontend/src/api/subscribeToRoomKeyEvents/index.ts @@ -0,0 +1,10 @@ +import { userRoomKey } from '../_transport/subjects' +import type { Nats, NatsSubscription, SubscriptionCallback } from '../types' + +/** Subscribe to the calling user's room-key event stream. */ +export function subscribeToRoomKeyEvents( + { subscribe, user }: Pick, + callback: SubscriptionCallback, +): NatsSubscription { + return subscribe(userRoomKey(user.account), callback) +} diff --git a/chat-frontend/src/api/types.ts b/chat-frontend/src/api/types.ts index 21dfaf2b5..f25010520 100644 --- a/chat-frontend/src/api/types.ts +++ b/chat-frontend/src/api/types.ts @@ -262,6 +262,19 @@ export interface SubscriptionUpdateEvent { timestamp: number } +/** + * Mirrors pkg/model.RoomKeyEvent — payload of + * chat.user.{account}.event.room.key. PrivateKey is base64-encoded on + * the wire (Go's encoding/json default for []byte). PublicKey is + * omitted from the client wire payload. + */ +export interface RoomKeyEvent { + roomId: string + version: number + privateKey: string // base64 + timestamp: number +} + /** Two-phase async-job result returned by `requestWithAsyncResult`. */ export interface AsyncJobResult { requestId: string diff --git a/chat-frontend/src/components/MainApp/MainApp.integration.test.jsx b/chat-frontend/src/components/MainApp/MainApp.integration.test.jsx index c0ea51bde..c795f54f7 100644 --- a/chat-frontend/src/components/MainApp/MainApp.integration.test.jsx +++ b/chat-frontend/src/components/MainApp/MainApp.integration.test.jsx @@ -23,6 +23,12 @@ import MainApp from './MainApp' // If this test passes but the user reports the panel doesn't appear, the // remaining suspects are CSS clipping or browser-specific (nginx caching). +// RoomEventsProvider calls useRoomKeys() internally. Stub it so this test +// doesn't need a real RoomKeysProvider (which would try to connect to NATS). +vi.mock('@/context/RoomKeysContext', () => ({ + useRoomKeys: () => ({ decrypt: async () => null, hasKey: () => false }), +})) + vi.mock('./AppHeader/AppHeader', () => ({ default: ({ onSelectRoom }) => (
diff --git a/chat-frontend/src/context/RoomEventsContext/RoomEventsContext.test.jsx b/chat-frontend/src/context/RoomEventsContext/RoomEventsContext.test.jsx index bee3f59f6..aa9fd77e5 100644 --- a/chat-frontend/src/context/RoomEventsContext/RoomEventsContext.test.jsx +++ b/chat-frontend/src/context/RoomEventsContext/RoomEventsContext.test.jsx @@ -6,6 +6,14 @@ import { RoomEventsProvider, useRoomEvents, useRoomSummaries, useSidebarSections import { BUFFER_MODE } from './reducer' // jumpToMessage / resetToLiveTail tests — see suite below +// RoomEventsContext now calls useRoomKeys() internally. Stub it out so tests +// don't need a real RoomKeysProvider (which would try to connect to NATS and +// fetch key material). The no-op decrypt matches the default used in +// useRoomSubscriptions when no key is available. +vi.mock('@/context/RoomKeysContext', () => ({ + useRoomKeys: () => ({ decrypt: async () => null, hasKey: () => false }), +})) + /** Turn an inline "room-shaped" fixture into a subscription record that * the new bootstrap (3 subscription RPCs) returns. The real user-service * embeds room metadata (userCount, lastMsgAt) inline on each subscription diff --git a/chat-frontend/src/context/RoomEventsContext/RoomEventsContext.tsx b/chat-frontend/src/context/RoomEventsContext/RoomEventsContext.tsx index 626ec0acc..bf0a28b99 100644 --- a/chat-frontend/src/context/RoomEventsContext/RoomEventsContext.tsx +++ b/chat-frontend/src/context/RoomEventsContext/RoomEventsContext.tsx @@ -9,6 +9,7 @@ import { type ReactNode, } from 'react' import { useNats } from '@/context/NatsContext' +import { useRoomKeys } from '@/context/RoomKeysContext' import { BUFFER_MODE, initialState, roomEventsReducer } from './reducer' import { useRoomSubscriptions } from './useRoomSubscriptions' import { useUnreadCount as useUnreadCountQuery } from './useUnreadCount' @@ -112,6 +113,7 @@ export function RoomEventsProvider({ children }: { children: ReactNode }) { // where the NATS handshake has populated user/request/etc. const nats = useNats() as unknown as Nats const { user } = nats + const { decrypt } = useRoomKeys() const [state, dispatch] = useReducer(roomEventsReducer, initialState) as unknown as [ RoomEventsState, Dispatch<{ type: string; [k: string]: unknown }>, @@ -147,6 +149,7 @@ export function RoomEventsProvider({ children }: { children: ReactNode }) { stateRef, threadReplyHandlerRef, threadMessageMutationHandlerRef, + decrypt, ) const loadHistory = useCallback( diff --git a/chat-frontend/src/context/RoomEventsContext/reducer.test.js b/chat-frontend/src/context/RoomEventsContext/reducer.test.js index 7eec28685..3e1be4c36 100644 --- a/chat-frontend/src/context/RoomEventsContext/reducer.test.js +++ b/chat-frontend/src/context/RoomEventsContext/reducer.test.js @@ -215,6 +215,32 @@ describe('roomEventsReducer: MESSAGE_RECEIVED', () => { }) }) + it('treats a successfully-decrypted MESSAGE_RECEIVED as a normal new-message event', () => { + // After Task 25, useRoomSubscriptions decrypts evt.encryptedMessage and + // dispatches with .message populated AND .encryptedMessage cleared. + // The reducer should see no difference between this and a plaintext event: + // no placeholder, content is the decoded plaintext, encrypted flag unset. + const event = newMessageEvent({ + message: { + id: 'm-decoded', + roomId: 'a', + content: 'decrypted body', + createdAt: '2026-05-20T00:00:00Z', + sender: { account: 'bob', engName: 'Bob' }, + }, + lastMsgId: 'm-decoded', + encryptedMessage: undefined, + }) + const next = roomEventsReducer(initialState, { + type: 'MESSAGE_RECEIVED', + event, + }) + const inserted = next.roomState.a.messages.find((m) => m.id === 'm-decoded') + expect(inserted).toBeDefined() + expect(inserted.content).toBe('decrypted body') + expect(inserted.encrypted).toBeFalsy() + }) + it('does not drop an event that has both message and encryptedMessage — plaintext wins', () => { // Forward-compatible: if a future broadcaster sends both lanes (e.g. // during a rollout), the plaintext path is authoritative. diff --git a/chat-frontend/src/context/RoomEventsContext/useRoomSubscriptions.js b/chat-frontend/src/context/RoomEventsContext/useRoomSubscriptions.js index ed0e42811..f267e3bbb 100644 --- a/chat-frontend/src/context/RoomEventsContext/useRoomSubscriptions.js +++ b/chat-frontend/src/context/RoomEventsContext/useRoomSubscriptions.js @@ -51,6 +51,11 @@ const MARK_READ_DEBOUNCE_MS = 500 * the hook reads `stateRef.current.activeRoomId` + `summaries` from * inside long-lived subscription callbacks to decide whether to fire * a `markRoomRead` RPC on incoming messages. + * + * @param {(input: { roomId: string; version: number; nonceB64: string; ciphertextB64: string }) => Promise} [decrypt] + * Room-message decryption function from RoomKeysContext. Defaults to a + * no-op that always returns null (pass-through: encrypted events reach + * the reducer's placeholder branch unchanged). */ export function useRoomSubscriptions( nats, @@ -58,6 +63,7 @@ export function useRoomSubscriptions( stateRef, threadReplyHandlerRef, threadMessageMutationHandlerRef, + decrypt = async () => null, ) { const { user } = nats // Keep a live ref to `nats` so long-lived subscription callbacks @@ -66,6 +72,11 @@ export function useRoomSubscriptions( const natsRef = useRef(nats) natsRef.current = nats + // Keep a live ref to `decrypt` so subscription callbacks always use + // the latest version without restarting the effect. + const decryptRef = useRef(decrypt) + decryptRef.current = decrypt + // Bumped on every login (re)cycle so the provider's async fetch // callbacks can detect stale-generation dispatches. const generationRef = useRef(0) @@ -203,36 +214,116 @@ export function useRoomSubscriptions( } } - const dmSub = subscribeToUserRoomEvents(liveNats, (evt) => { - if (evt?.type === 'new_message') { - safeDispatch({ type: 'MESSAGE_RECEIVED', event: evt }) - fanThreadReply(evt) - // Thread replies don't advance the main-feed lastSeenAt. - if (!evt.message?.threadParentMessageId) { - scheduleMarkActiveRead(evt.roomId) - } + // Per-room dispatch chains. Each entry is a Promise representing the + // most recent in-flight work for that room. New events for the same + // room chain off it via .then(fn, fn) so they observe the same order + // they arrived in even when some are encrypted (await deriveAesKey + + // GCM.open) and others are plaintext (synchronous). Without this, + // a plaintext mutation event can finalize before a prior encrypted + // new_message resolves, scrambling the message-list order. + const dispatchChains = new Map() + const enqueueByRoom = (roomId, work) => { + if (!roomId) { + work() return } - handleMutationEvent(evt) + const prev = dispatchChains.get(roomId) ?? Promise.resolve() + const next = prev.then(work, work) + dispatchChains.set(roomId, next) + } + + // Decrypt encrypted fields on an event, then call finalize(decoded). + // Handles two cases: + // 1. encryptedMessage (new_message with no plaintext body yet) + // 2. messageEdited.encryptedNewContent (edit events in encrypted rooms) + // Returns null on the key-not-yet-available path — the caller passes + // the event through unchanged and the reducer's placeholder branch + // handles the missing body gracefully. + const decryptAndDispatch = async (evt, finalize) => { + let decoded = evt + try { + // Handle encrypted full-message events. + if (decoded.encryptedMessage && !decoded.message) { + const enc = decoded.encryptedMessage + if (typeof enc.version === 'number' && enc.nonce && enc.ciphertext) { + const plaintext = await decryptRef.current({ + roomId: decoded.roomId, + version: enc.version, + nonceB64: enc.nonce, + ciphertextB64: enc.ciphertext, + }) + if (plaintext != null) { + const msg = JSON.parse(plaintext) + decoded = { ...decoded, message: msg, encryptedMessage: undefined } + } + } + } + // Handle encrypted message edits. + if (decoded.messageEdited && decoded.messageEdited.encryptedNewContent && !decoded.messageEdited.newContent) { + const enc = decoded.messageEdited.encryptedNewContent + if (typeof enc.version === 'number' && enc.nonce && enc.ciphertext) { + const plaintext = await decryptRef.current({ + roomId: decoded.roomId, + version: enc.version, + nonceB64: enc.nonce, + ciphertextB64: enc.ciphertext, + }) + if (plaintext != null) { + decoded = { + ...decoded, + messageEdited: { ...decoded.messageEdited, newContent: plaintext, encryptedNewContent: undefined }, + } + } + } + } + } catch (err) { + // eslint-disable-next-line no-console + console.warn('decryptAndDispatch failed; forwarding original event', err) + } + finalize(decoded) + } + + const dmSub = subscribeToUserRoomEvents(liveNats, (evt) => { + enqueueByRoom(evt?.roomId, () => { + if (evt?.type === 'new_message') { + return decryptAndDispatch(evt, (decoded) => { + safeDispatch({ type: 'MESSAGE_RECEIVED', event: decoded }) + fanThreadReply(decoded) + // Thread replies don't advance the main-feed lastSeenAt. + if (!decoded.message?.threadParentMessageId) { + scheduleMarkActiveRead(decoded.roomId) + } + }) + } + handleMutationEvent(evt) + }) }) const openChannelSub = (roomId) => { if (channelSubs.current.has(roomId)) return const sub = subscribeToRoomEvents(natsRef.current, { roomId }, (evt) => { - if (evt?.type === 'new_message') { - const hasMention = (evt.mentions ?? []).some( - (p) => p.account === user.account - ) - const normalized = { ...evt, hasMention } - safeDispatch({ type: 'MESSAGE_RECEIVED', event: normalized }) - fanThreadReply(normalized) - // See dm path above — skip main-feed mark-read for thread replies. - if (!evt.message?.threadParentMessageId) { - scheduleMarkActiveRead(evt.roomId ?? roomId) + enqueueByRoom(evt?.roomId ?? roomId, () => { + if (evt?.type === 'new_message') { + return decryptAndDispatch(evt, (decoded) => { + const hasMention = (decoded.mentions ?? []).some( + (p) => p.account === user.account + ) + const normalized = { ...decoded, hasMention } + safeDispatch({ type: 'MESSAGE_RECEIVED', event: normalized }) + fanThreadReply(normalized) + // See dm path above — skip main-feed mark-read for thread replies. + if (!decoded.message?.threadParentMessageId) { + scheduleMarkActiveRead(decoded.roomId ?? roomId) + } + }) } - return - } - handleMutationEvent(evt) + if (evt?.type === 'message_edited') { + return decryptAndDispatch(evt, (decoded) => { + handleMutationEvent(decoded) + }) + } + handleMutationEvent(evt) + }) }) channelSubs.current.set(roomId, sub) } @@ -319,6 +410,7 @@ export function useRoomSubscriptions( metaUpdate.unsubscribe() for (const sub of channelSubs.current.values()) sub.unsubscribe() channelSubs.current.clear() + dispatchChains.clear() // Cancel any in-flight mark-read trailing timer so it doesn't // fire after teardown (would `markRoomRead` against a dead nc). if (markReadTimeoutRef.current) { diff --git a/chat-frontend/src/context/RoomKeysContext/RoomKeysContext.test.jsx b/chat-frontend/src/context/RoomKeysContext/RoomKeysContext.test.jsx new file mode 100644 index 000000000..86c973f15 --- /dev/null +++ b/chat-frontend/src/context/RoomKeysContext/RoomKeysContext.test.jsx @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, waitFor, act } from '@testing-library/react' +import { RoomKeysProvider, useRoomKeys } from './RoomKeysContext' + +vi.mock('@/api', () => ({ + subscribeToRoomKeyEvents: vi.fn(), +})) + +vi.mock('@/context/NatsContext', () => ({ + useNats: () => ({ + user: { account: 'alice', id: 'u1' }, + request: vi.fn(), + subscribe: vi.fn(), + publish: vi.fn(), + requestWithAsyncResult: vi.fn(), + connected: true, + error: null, + }), +})) + +import { subscribeToRoomKeyEvents } from '@/api' + +let lastDecryptHook = null + +function Probe() { + lastDecryptHook = useRoomKeys() + return null +} + +describe('RoomKeysProvider', () => { + beforeEach(() => { + lastDecryptHook = null + vi.mocked(subscribeToRoomKeyEvents).mockReset() + }) + + it('dispatches KEY_RECEIVED for each live event', async () => { + let savedCb + vi.mocked(subscribeToRoomKeyEvents).mockImplementation((_n, cb) => { + savedCb = cb + return { unsubscribe: vi.fn() } + }) + + render( + + + , + ) + + await waitFor(() => expect(savedCb).toBeDefined()) + + act(() => { + savedCb({ + roomId: 'r2', + version: 3, + privateKey: btoa(String.fromCharCode(...new Uint8Array(32).fill(1))), + timestamp: Date.now(), + }) + }) + + await waitFor(() => expect(lastDecryptHook?.hasKey('r2', 3)).toBe(true)) + }) + + it('unsubscribes and clears state on unmount', async () => { + const unsub = { unsubscribe: vi.fn() } + vi.mocked(subscribeToRoomKeyEvents).mockReturnValue(unsub) + + const { unmount } = render( + + + , + ) + unmount() + expect(unsub.unsubscribe).toHaveBeenCalled() + }) +}) diff --git a/chat-frontend/src/context/RoomKeysContext/RoomKeysContext.tsx b/chat-frontend/src/context/RoomKeysContext/RoomKeysContext.tsx new file mode 100644 index 000000000..e8f954901 --- /dev/null +++ b/chat-frontend/src/context/RoomKeysContext/RoomKeysContext.tsx @@ -0,0 +1,135 @@ +import { createContext, useCallback, useContext, useEffect, useReducer, useRef } from 'react' +import { subscribeToRoomKeyEvents } from '@/api' +import type { Nats, RoomKeyEvent } from '@/api' +import { useNats } from '@/context/NatsContext' +import { b64decode, importAesKey, decryptRoomMessage } from '@/lib/roomcrypto' +import { bytesEqual, initialRoomKeysState, roomKeysReducer } from './reducer' + +type DecryptInput = { + roomId: string + version: number + nonceB64: string + ciphertextB64: string +} + +type RoomKeysContextValue = { + hasKey(roomId: string, version: number): boolean + /** Returns null if the key is not (yet) known for that (roomId, version), + * or if decryption fails. */ + decrypt(input: DecryptInput): Promise +} + +const RoomKeysContext = createContext(null) + +export function useRoomKeys(): RoomKeysContextValue { + const ctx = useContext(RoomKeysContext) + if (!ctx) throw new Error('useRoomKeys called outside RoomKeysProvider') + return ctx +} + +export function RoomKeysProvider({ children }: { children: React.ReactNode }) { + const [state, dispatch] = useReducer(roomKeysReducer, initialRoomKeysState) + // `useNats()` returns `never` to TS because NatsContext.jsx does + // `createContext(null)` without annotations. Cast here so downstream + // callbacks see the proper Nats interface — safe because the + // provider only renders inside the `connected` gate at App.jsx, + // where the NATS handshake has populated user/request/etc. + const nats = useNats() as unknown as Nats + + // CryptoKey cache lives in a ref — imported lazily, not React state. + // Keyed by `${roomId}|${version}`. + const aesKeyCacheRef = useRef>>(new Map()) + const stateRef = useRef(state) + stateRef.current = state + + // Keep a live ref to `nats` so long-lived subscription callbacks see + // the latest connection without forcing the effect to re-run. The + // effect depends only on user.account (a stable primitive) so it + // rebuilds subs only when login actually changes — not on every nats + // context value re-memoisation (see useRoomSubscriptions for prior art). + const natsRef = useRef(nats) + natsRef.current = nats + + const userAccount = nats.user?.account ?? null + + useEffect(() => { + if (!userAccount) return + + const liveNats = natsRef.current + + // TODO: initial keys arrive via the subscription.get* RPCs (user-service + // responsibility — to be extended in a follow-up). Until then, + // RoomKeysContext populates from live RoomKeyEvent subscriptions only; + // reconnecting users re-acquire keys when a rotation or membership change + // next fires for each room. + const sub = subscribeToRoomKeyEvents(liveNats, (raw) => { + const evt = raw as RoomKeyEvent + if (!evt || typeof evt.roomId !== 'string' || typeof evt.version !== 'number' || typeof evt.privateKey !== 'string') return + let privateKey: Uint8Array + try { + privateKey = b64decode(evt.privateKey) + } catch (err) { + // eslint-disable-next-line no-console + console.warn('roomKeyEvent: invalid base64 privateKey, dropping event', err) + return + } + // Skip evicting the cached AES key when the rebroadcast bytes match + // the stored bytes — the reducer no-ops on that path, so dropping + // the derived CryptoKey would force a redundant deriveKey call. + const existing = stateRef.current.byRoom[evt.roomId]?.[evt.version] + if (!existing || !bytesEqual(existing.privateKey, privateKey)) { + aesKeyCacheRef.current.delete(`${evt.roomId}|${evt.version}`) + } + dispatch({ + type: 'KEY_RECEIVED', + roomId: evt.roomId, + version: evt.version, + privateKey, + }) + }) + + return () => { + sub.unsubscribe() + aesKeyCacheRef.current.clear() + dispatch({ type: 'CLEAR_KEYS' }) + } + // userAccount is a stable primitive (set once on login). + // natsRef is always current — no need to list it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userAccount]) + + const hasKey = useCallback((roomId: string, version: number) => { + return !!stateRef.current.byRoom[roomId]?.[version] + }, []) + + const decrypt = useCallback(async ({ roomId, version, nonceB64, ciphertextB64 }: DecryptInput): Promise => { + const entry = stateRef.current.byRoom[roomId]?.[version] + if (!entry) return null + + const cacheKey = `${roomId}|${version}` + let pending = aesKeyCacheRef.current.get(cacheKey) + if (!pending) { + pending = importAesKey(entry.privateKey) + aesKeyCacheRef.current.set(cacheKey, pending) + } + try { + const aesKey = await pending + return await decryptRoomMessage(b64decode(ciphertextB64), b64decode(nonceB64), aesKey) + } catch (err) { + // Drop the cached promise so a subsequent decrypt retries derivation + // instead of awaiting the same rejected promise forever. If the cache + // entry was already replaced by a newer event between read and catch, + // only delete our own — peek before evicting. + if (aesKeyCacheRef.current.get(cacheKey) === pending) { + aesKeyCacheRef.current.delete(cacheKey) + } + // eslint-disable-next-line no-console + console.warn('roomKeysContext.decrypt failed:', err) + return null + } + }, []) + + const value: RoomKeysContextValue = { hasKey, decrypt } + + return {children} +} diff --git a/chat-frontend/src/context/RoomKeysContext/index.tsx b/chat-frontend/src/context/RoomKeysContext/index.tsx new file mode 100644 index 000000000..c177059c4 --- /dev/null +++ b/chat-frontend/src/context/RoomKeysContext/index.tsx @@ -0,0 +1 @@ +export { RoomKeysProvider, useRoomKeys } from './RoomKeysContext' diff --git a/chat-frontend/src/context/RoomKeysContext/reducer.test.ts b/chat-frontend/src/context/RoomKeysContext/reducer.test.ts new file mode 100644 index 000000000..ca1c589ff --- /dev/null +++ b/chat-frontend/src/context/RoomKeysContext/reducer.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest' +import { roomKeysReducer, initialRoomKeysState, type RoomKeysState } from './reducer' + +const seed = (): RoomKeysState => ({ ...initialRoomKeysState, byRoom: { ...initialRoomKeysState.byRoom } }) + +describe('roomKeysReducer', () => { + it('KEY_RECEIVED inserts a single (roomId, version) entry', () => { + const next = roomKeysReducer(seed(), { + type: 'KEY_RECEIVED', + roomId: 'r1', + version: 2, + privateKey: new Uint8Array([7]), + }) + expect(next.byRoom.r1[2].privateKey).toEqual(new Uint8Array([7])) + }) + + it('KEY_RECEIVED is idempotent — same input does not duplicate', () => { + const action = { + type: 'KEY_RECEIVED' as const, + roomId: 'r1', + version: 2, + privateKey: new Uint8Array([7]), + } + const once = roomKeysReducer(seed(), action) + const twice = roomKeysReducer(once, action) + expect(Object.keys(twice.byRoom.r1)).toEqual(['2']) + }) + + it('KEY_RECEIVED keeps at most MAX_VERSIONS_PER_ROOM newest versions', () => { + let s = seed() + // Insert versions 1..5 — MAX_VERSIONS_PER_ROOM is 2, so only 4 and 5 should remain. + for (let v = 1; v <= 5; v++) { + s = roomKeysReducer(s, { + type: 'KEY_RECEIVED', + roomId: 'r1', + version: v, + privateKey: new Uint8Array([v]), + }) + } + const versions = Object.keys(s.byRoom.r1).map(Number).sort((a, b) => a - b) + expect(versions).toEqual([4, 5]) + }) + + it('CLEAR_KEYS resets state', () => { + const populated: RoomKeysState = { + byRoom: { r1: { 1: { privateKey: new Uint8Array([1]) } } }, + } + const next = roomKeysReducer(populated, { type: 'CLEAR_KEYS' }) + expect(next).toEqual(initialRoomKeysState) + }) +}) diff --git a/chat-frontend/src/context/RoomKeysContext/reducer.ts b/chat-frontend/src/context/RoomKeysContext/reducer.ts new file mode 100644 index 000000000..db13142da --- /dev/null +++ b/chat-frontend/src/context/RoomKeysContext/reducer.ts @@ -0,0 +1,61 @@ +/** Maximum stored versions per room. Matches Valkey's previous-key grace + * slot (one previous in addition to current). */ +export const MAX_VERSIONS_PER_ROOM = 2 + +export type StoredKey = { + privateKey: Uint8Array +} + +export type RoomKeysState = { + byRoom: Record> +} + +export const initialRoomKeysState: RoomKeysState = { + byRoom: {}, +} + +export type RoomKeysAction = + | { + type: 'KEY_RECEIVED' + roomId: string + version: number + privateKey: Uint8Array + } + | { type: 'CLEAR_KEYS' } + +export function roomKeysReducer(state: RoomKeysState, action: RoomKeysAction): RoomKeysState { + switch (action.type) { + case 'KEY_RECEIVED': { + const existing = state.byRoom[action.roomId]?.[action.version] + if (existing && bytesEqual(existing.privateKey, action.privateKey)) { + return state // idempotent no-op + } + const room = { ...(state.byRoom[action.roomId] ?? {}) } + room[action.version] = { privateKey: action.privateKey } + return { + ...state, + byRoom: { ...state.byRoom, [action.roomId]: trimVersions(room) }, + } + } + case 'CLEAR_KEYS': + return initialRoomKeysState + default: + return state + } +} + +function trimVersions(room: Record): Record { + const versions = Object.keys(room).map(Number).sort((a, b) => b - a) + if (versions.length <= MAX_VERSIONS_PER_ROOM) return room + const out: Record = {} + for (const v of versions.slice(0, MAX_VERSIONS_PER_ROOM)) { + out[v] = room[v] + } + return out +} + +export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false + return true +} diff --git a/chat-frontend/src/lib/roomcrypto/index.ts b/chat-frontend/src/lib/roomcrypto/index.ts new file mode 100644 index 000000000..51b136b81 --- /dev/null +++ b/chat-frontend/src/lib/roomcrypto/index.ts @@ -0,0 +1 @@ +export { b64decode, importAesKey, decryptRoomMessage } from './roomcrypto' diff --git a/chat-frontend/src/lib/roomcrypto/roomcrypto.test.ts b/chat-frontend/src/lib/roomcrypto/roomcrypto.test.ts new file mode 100644 index 000000000..1945b0b3a --- /dev/null +++ b/chat-frontend/src/lib/roomcrypto/roomcrypto.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' +import { b64decode, decryptRoomMessage, importAesKey } from './roomcrypto' +import fixture from '../../../test/fixtures/encrypted-message.json' + +describe('b64decode', () => { + it('decodes a known base64 string', () => { + expect(Array.from(b64decode('aGVsbG8='))).toEqual([104, 101, 108, 108, 111]) + }) + + it('decodes an empty string to an empty Uint8Array', () => { + expect(b64decode('').length).toBe(0) + }) + + it('round-trips with btoa', () => { + const original = new Uint8Array([1, 2, 3, 250]) + const encoded = btoa(String.fromCharCode(...original)) + expect(Array.from(b64decode(encoded))).toEqual([1, 2, 3, 250]) + }) +}) + +describe('importAesKey', () => { + it('returns a non-extractable AES-GCM CryptoKey usable for decrypt', async () => { + const priv = new Uint8Array(32) + priv.fill(0x42) + const key = await importAesKey(priv) + expect(key.type).toBe('secret') + expect(key.algorithm).toMatchObject({ name: 'AES-GCM', length: 256 }) + expect(key.usages).toEqual(['decrypt']) + expect(key.extractable).toBe(false) + }) + + it('rejects a private key of wrong length', async () => { + await expect(importAesKey(new Uint8Array(31))).rejects.toThrow(/32 bytes/) + }) +}) + +describe('decryptRoomMessage', () => { + it('decrypts a fixture produced by the Go server encoder', async () => { + // Cross-language round-trip via the committed fixture. This exercises + // the full chain (import + AES-GCM open) against bytes that the + // Go server actually emits — stronger than an inline-encrypt test. + const aesKey = await importAesKey(b64decode(fixture.privateKey)) + const plaintext = await decryptRoomMessage( + b64decode(fixture.message.ciphertext), + b64decode(fixture.message.nonce), + aesKey, + ) + expect(plaintext).toBe(fixture.plaintext) + }) + + it('throws on tag mismatch', async () => { + const priv = new Uint8Array(32) + priv.fill(0x11) + const aesKey = await importAesKey(priv) + const nonce = new Uint8Array(12) + const bogusCiphertext = new Uint8Array(32) // all-zero bytes; GCM tag fails + await expect(decryptRoomMessage(bogusCiphertext, nonce, aesKey)).rejects.toBeDefined() + }) +}) diff --git a/chat-frontend/src/lib/roomcrypto/roomcrypto.ts b/chat-frontend/src/lib/roomcrypto/roomcrypto.ts new file mode 100644 index 000000000..ce3496114 --- /dev/null +++ b/chat-frontend/src/lib/roomcrypto/roomcrypto.ts @@ -0,0 +1,55 @@ +/** + * Decode a standard-base64 string to a Uint8Array. + * + * Note: this is base64, not base64url. The server emits standard base64 + * via Go's encoding/json default for []byte fields (StdEncoding). + */ +export function b64decode(s: string): Uint8Array { + const binary = atob(s) + const out = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) + return out +} + +/** + * Import a 32-byte room private key as a non-extractable AES-256-GCM + * CryptoKey for decryption. The room secret is uniform random material + * and is used directly as the AES-256 key without any key derivation step. + * + * The returned key is non-extractable and has the single usage 'decrypt'. + */ +export async function importAesKey(roomPrivateKey: Uint8Array): Promise { + if (roomPrivateKey.length !== 32) { + throw new Error(`room private key must be 32 bytes, got ${roomPrivateKey.length}`) + } + return crypto.subtle.importKey( + 'raw', + roomPrivateKey.buffer.slice(roomPrivateKey.byteOffset, roomPrivateKey.byteOffset + roomPrivateKey.byteLength) as ArrayBuffer, + { name: 'AES-GCM', length: 256 }, + false, + ['decrypt'], + ) +} + +/** + * Decrypt a server-produced {nonce, ciphertext} pair using the AES key + * imported via importAesKey. The ciphertext is body || 16-byte GCM tag, + * matching Go's cipher.AEAD.Seal output. + */ +export async function decryptRoomMessage( + ciphertext: Uint8Array, + nonce: Uint8Array, + aesKey: CryptoKey, +): Promise { + if (nonce.length !== 12) { + throw new Error(`nonce must be 12 bytes, got ${nonce.length}`) + } + const ivBuffer = nonce.buffer.slice(nonce.byteOffset, nonce.byteOffset + nonce.byteLength) as ArrayBuffer + const ctBuffer = ciphertext.buffer.slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength) as ArrayBuffer + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: ivBuffer, tagLength: 128 }, + aesKey, + ctBuffer, + ) + return new TextDecoder('utf-8').decode(plaintext) +} diff --git a/chat-frontend/test/fixtures/encrypted-message.json b/chat-frontend/test/fixtures/encrypted-message.json new file mode 100644 index 000000000..1649a30b9 --- /dev/null +++ b/chat-frontend/test/fixtures/encrypted-message.json @@ -0,0 +1,9 @@ +{ + "privateKey": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=", + "plaintext": "fixture plaintext — encoded by the Go server, decoded by chat-frontend", + "message": { + "version": 1, + "nonce": "AAAAAAAAAAAAAAAA", + "ciphertext": "oBCjbcgncMVvRoVlGUTPgr9Xa4arlOlNDgUi0OPnlS5T/XELAmEELV0U2tgt1sfGwIg3QClV+arWLvN6mwVZYouONcolyueTq6J0SpCSQW7ZmWW3kkwzVw==" + } +} \ No newline at end of file diff --git a/docs/client-api.md b/docs/client-api.md index fa17fb056..2138af1d7 100644 --- a/docs/client-api.md +++ b/docs/client-api.md @@ -1891,7 +1891,7 @@ A `RoomEvent` published by `broadcast-worker`. Recipients: every client subscrib | `mentionAll` | boolean | Optional. `true` if the message mentioned `@all` or `@here`. | | `hasMention` | boolean | Optional. Per-recipient flag (DM event only). Always absent on channel events. | | `message` | object | Optional. The `ClientMessage` (see [Message schema](#message-schema) plus a `sender` Participant). Set for unencrypted rooms. | -| `encryptedMessage` | object | Optional. Raw `roomcrypto.EncryptedMessage` JSON. Set for encrypted (channel) rooms. Use the room's current key to decrypt. | +| `encryptedMessage` | object | Optional. The room ciphertext envelope `{version, nonce, ciphertext}` (see [§5.1](#51-room-encryption-keys)). Set for encrypted (channel) rooms. Clients decrypt by deriving the AES-256-GCM key from the room private key for `version` and unsealing with `nonce` + `ciphertext`. | ```json { @@ -1905,9 +1905,9 @@ A `RoomEvent` published by `broadcast-worker`. Recipients: every client subscrib "lastMsgAt": "2026-05-06T07:55:00Z", "lastMsgId": "01970a4f8c2d7c9aQRST", "encryptedMessage": { - "v": 3, - "ciphertext": "", - "nonce": "" + "version": 3, + "nonce": "", + "ciphertext": "" } } ``` @@ -1987,7 +1987,7 @@ Server-pushed events are delivered to clients on NATS subjects the client is alr ### 5.1 Room Encryption Keys -Each room has a P-256 keypair generated server-side at create time. Channel rooms use the key for end-to-end message encryption: `broadcast-worker` populates `encryptedMessage` on channel events (§4.1) and clients use the private key to decrypt. DM and botDM rooms still receive a `RoomKeyEvent` at create time for implementation consistency, but currently broadcast plaintext `message` (no `encryptedMessage`), so clients may skip persisting DM/botDM keys. +Each room has a 32-byte secret generated server-side at create time (`crypto/rand`). The secret is distributed to channel members and used directly as an AES-256-GCM key — no key derivation step. DM and botDM rooms receive a `RoomKeyEvent` at create time for implementation consistency, but currently broadcast plaintext `message` (no `encryptedMessage`), so clients may skip persisting DM/botDM keys. #### Subject @@ -2003,17 +2003,21 @@ Clients are already authorized for `chat.user.{theirAccount}.>` and receive key { "roomId": "", "version": 0, - "privateKey": "", + "privateKey": "", "timestamp": 1747000000000 } ``` -`[]byte` fields marshal to standard base64 in JSON. The room's public key is server-side only (used by `broadcast-worker` to encrypt outgoing messages) and is not transmitted to clients — clients only need the private key to decrypt incoming ciphertext. +`[]byte` fields marshal to standard base64 in JSON. The `privateKey` is the 32-byte room secret used directly as the AES-256-GCM key; no public key field is transmitted. #### Client behavior 1. On every `RoomKeyEvent`, store the key under `(roomId, version) → privateKey`. -2. When decrypting an incoming message, use the `version` stamped in the encrypted payload to look up the corresponding private key. +2. To decrypt an incoming `encryptedMessage` payload: + - Look up `privateKey` for `(roomId, encryptedMessage.version)`. + - Use the 32-byte `privateKey` directly as the AES-256-GCM key (no key derivation step). + - Decrypt: `AES-GCM-Decrypt(privateKey, nonce, ciphertext, aad=empty)`. The ciphertext already includes the 16-byte GCM tag at the end (Go `cipher.AEAD.Seal` format). + - The plaintext is a UTF-8-encoded JSON `ClientMessage` (for `encryptedMessage`) or a UTF-8 string (for `messageEdited.encryptedNewContent`). 3. Retain past versions to support history scrolling. The server retains the previous version in its store for at least `VALKEY_KEY_GRACE_PERIOD` (default 24h); after that, server-side decryption of old messages may not be possible, but clients holding old keys can still decrypt locally. #### When clients receive `RoomKeyEvent`s @@ -2024,6 +2028,8 @@ Clients are already authorized for `chat.user.{theirAccount}.>` and receive key Removed members keep prior keys for decrypting historical messages but cannot decrypt anything published after the rotation. +**Initial key bootstrap on (re)connect:** live `RoomKeyEvent`s fire only when keys change. The initial set of keys for rooms the client is already subscribed to will be delivered as part of the `subscription.get*` RPC family (see user-service — to be documented). Until that extension lands, clients receive keys only via live events. + --- ## 6. Error envelope reference diff --git a/docs/superpowers/plans/2026-05-20-ecdh-performance-analysis.md b/docs/superpowers/plans/2026-05-20-ecdh-performance-analysis.md new file mode 100644 index 000000000..361cf0161 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-ecdh-performance-analysis.md @@ -0,0 +1,2675 @@ +# ECDH Performance — HKDF-Only Versioned Symmetric Key Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the ECIES-style per-message ephemeral-key encryption in `pkg/roomcrypto` with a versioned symmetric AES-256-GCM key derived once per `(roomId, version)` via HKDF-SHA256. Implement the first chat-frontend decoder end-to-end so the in-repo client decrypts real messages instead of rendering a placeholder. Drop the legacy scheme entirely; no dual-scheme migration window. + +**Architecture:** Server-side: a new `roomcrypto.Encoder` type owns a per-`(roomId, version)` `cipher.AEAD` cache; `broadcast-worker` constructs one in `main.go` and replaces direct `Encode()` calls. Client-side: a new `RoomKeysContext` bootstraps room private keys via a new `room-service` RPC, subscribes to live key-rotation events, lazily derives `CryptoKey`s, and exposes `decryptEvent(evt)` to the `RoomEvents` dispatcher so the reducer only ever sees fully-decoded events. + +**Tech Stack:** Go 1.25 (`crypto/aes`, `crypto/cipher`, `crypto/rand`, `golang.org/x/crypto/hkdf`); TypeScript / React (Web Crypto `crypto.subtle`); NATS request/reply + subscribe; Vitest + @testing-library/react. + +**Spec reference:** `docs/superpowers/specs/2026-05-20-ecdh-performance-analysis-design.md` + +--- + +## File map + +### Server (Go) + +| Path | What changes | +|---|---| +| `pkg/roomcrypto/roomcrypto.go` | Drop `EphemeralPublicKey`; remove free `Encode`; add `Encoder` struct, `NewEncoder` + options, `(*Encoder).Encode` | +| `pkg/roomcrypto/roomcrypto_test.go` | Remove legacy tests; add tests for `Encoder` | +| `pkg/roomcrypto/bench_test.go` | Update to benchmark `(*Encoder).Encode` | +| `pkg/roomcrypto/integration_test.go` | Update TS decrypt script + fixtures for new scheme | +| `pkg/roomcrypto/testdata/decrypt.ts` | Rewrite TS reference decoder | +| `broadcast-worker/handler.go` | Hold `*roomcrypto.Encoder`; call `Encode(roomID, content, privKey, version)` | +| `broadcast-worker/handler_test.go` | Pass real encoder into handler under test | +| `broadcast-worker/main.go` | Parse `ROOM_CRYPTO_CACHE_SIZE`; construct encoder | +| `pkg/subject/subject.go` | Add `RoomsKeysBootstrap(account)`, `RoomsKeysBootstrapWildcard()` | +| `pkg/model/event.go` | Add `RoomsKeysResponse`, `RoomsKeysEntry` | +| `room-service/handler.go` | New `natsListRoomKeys` handler; register in `RegisterCRUD` | +| `room-service/handler_test.go` | Tests for `natsListRoomKeys` | +| `docs/client-api.md` | Document new wire format + new RPC | + +### chat-frontend (TS/JS) + +| Path | What changes | +|---|---| +| `chat-frontend/src/lib/roomcrypto/roomcrypto.ts` | NEW — `deriveAesKey`, `decryptRoomMessage`, `b64decode` | +| `chat-frontend/src/lib/roomcrypto/roomcrypto.test.ts` | NEW — round-trip + error cases | +| `chat-frontend/src/lib/roomcrypto/index.ts` | NEW — barrel | +| `chat-frontend/scripts/gen-crypto-fixtures.go` | NEW — generates a known-plaintext fixture | +| `chat-frontend/test/fixtures/encrypted-message.json` | NEW — committed fixture | +| `chat-frontend/src/api/_transport/subjects.ts` | Add `userRoomKey`, `roomsKeysBootstrap` | +| `chat-frontend/src/api/_transport/subjects.test.js` | Cover the new builders | +| `chat-frontend/src/api/types.ts` | Add `RoomKeyEvent`, `RoomKeysEntry`, `RoomKeysResponse` | +| `chat-frontend/src/api/subscribeToRoomKeyEvents/index.ts` | NEW op | +| `chat-frontend/src/api/subscribeToRoomKeyEvents/index.test.ts` | NEW | +| `chat-frontend/src/api/fetchRoomKeysBootstrap/index.ts` | NEW op | +| `chat-frontend/src/api/fetchRoomKeysBootstrap/index.test.ts` | NEW | +| `chat-frontend/src/api/index.ts` | Add new barrel re-exports | +| `chat-frontend/src/context/RoomKeysContext/reducer.ts` | NEW | +| `chat-frontend/src/context/RoomKeysContext/reducer.test.ts` | NEW | +| `chat-frontend/src/context/RoomKeysContext/RoomKeysContext.tsx` | NEW | +| `chat-frontend/src/context/RoomKeysContext/RoomKeysContext.test.tsx` | NEW | +| `chat-frontend/src/context/RoomKeysContext/index.tsx` | NEW — barrel | +| `chat-frontend/src/App.jsx` | Wrap in `` | +| `chat-frontend/src/context/RoomEventsContext/RoomEventsContext.tsx` | Pass `decryptEvent` to `useRoomSubscriptions` | +| `chat-frontend/src/context/RoomEventsContext/useRoomSubscriptions.js` | Decrypt before dispatch | +| `chat-frontend/src/context/RoomEventsContext/reducer.test.js` | Add scheme-1 round-trip case | + +--- + +## Phase 1 — Server `pkg/roomcrypto` rewrite + +### Task 1: Add `Encoder` struct alongside legacy `Encode` (red) + +**Files:** +- Test: `pkg/roomcrypto/roomcrypto_test.go` + +The legacy `Encode` stays for now so `broadcast-worker` keeps compiling. We add the new type alongside. + +- [ ] **Step 1: Append the new failing test to `roomcrypto_test.go`** + +```go +func TestEncoder_Encode_HappyPath(t *testing.T) { + // Use a fixed 32-byte private key (a P-256 scalar's worth of entropy). + priv := make([]byte, 32) + for i := range priv { + priv[i] = byte(i + 1) + } + + enc := NewEncoder() + got, err := enc.Encode("room-1", "hello", priv, 7) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, 7, got.Version) + assert.Len(t, got.Nonce, 12) + assert.NotEmpty(t, got.Ciphertext) + // EphemeralPublicKey field must be empty/unset on the new scheme output. + assert.Empty(t, got.EphemeralPublicKey) +} +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +Run: `go test ./pkg/roomcrypto/ -run TestEncoder_Encode_HappyPath -v` +Expected: FAIL — `undefined: NewEncoder` (or similar). + +- [ ] **Step 3: Implement `Encoder` in `pkg/roomcrypto/roomcrypto.go`** + +Append to the bottom of the file (do NOT modify the existing legacy `Encode` yet): + +```go +// Encoder holds the per-(roomId, version) AES-GCM cipher cache for the +// HKDF-only encryption scheme. Construct one per process and share it +// across goroutines. +type Encoder struct { + mu sync.RWMutex + cache map[encoderCacheKey]cipher.AEAD + rand io.Reader + max int +} + +type encoderCacheKey struct { + roomID string + version int +} + +// EncoderOption configures an Encoder at construction time. +type EncoderOption func(*Encoder) + +// WithMaxCacheEntries sets the upper bound on the per-(roomId, version) +// AES-GCM cache. When exceeded, the entry with the lowest version is +// evicted. Default 4096. +func WithMaxCacheEntries(n int) EncoderOption { + return func(e *Encoder) { e.max = n } +} + +// WithRand overrides the source of randomness used for nonce generation. +// Intended for testing only. +func WithRand(r io.Reader) EncoderOption { + return func(e *Encoder) { e.rand = r } +} + +// NewEncoder constructs an Encoder with default cache size 4096 and +// crypto/rand.Reader as the randomness source. +func NewEncoder(opts ...EncoderOption) *Encoder { + e := &Encoder{ + cache: make(map[encoderCacheKey]cipher.AEAD), + rand: rand.Reader, + max: 4096, + } + for _, opt := range opts { + opt(e) + } + return e +} + +// Encode encrypts content under the AES key derived from roomPrivateKey +// for the given (roomID, version). The derived AES-GCM cipher is cached +// on the Encoder; repeat calls for the same (roomID, version) skip key +// derivation. +func (e *Encoder) Encode(roomID, content string, roomPrivateKey []byte, version int) (*EncryptedMessage, error) { + gcm, err := e.aeadFor(roomID, roomPrivateKey, version) + if err != nil { + return nil, err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(e.rand, nonce); err != nil { + return nil, fmt.Errorf("generating nonce: %w", err) + } + + ciphertext := gcm.Seal(nil, nonce, []byte(content), nil) + return &EncryptedMessage{ + Version: version, + Nonce: nonce, + Ciphertext: ciphertext, + }, nil +} + +func (e *Encoder) aeadFor(roomID string, roomPrivateKey []byte, version int) (cipher.AEAD, error) { + key := encoderCacheKey{roomID: roomID, version: version} + + e.mu.RLock() + gcm, ok := e.cache[key] + e.mu.RUnlock() + if ok { + return gcm, nil + } + + if len(roomPrivateKey) != 32 { + return nil, fmt.Errorf("room private key must be 32 bytes, got %d", len(roomPrivateKey)) + } + + aesKey := make([]byte, 32) + r := hkdf.New(sha256.New, roomPrivateKey, nil, []byte("room-message-encryption-v2")) + if _, err := io.ReadFull(r, aesKey); err != nil { + return nil, fmt.Errorf("deriving AES key: %w", err) + } + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, fmt.Errorf("creating AES cipher: %w", err) + } + newGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("creating GCM wrapper: %w", err) + } + + e.mu.Lock() + defer e.mu.Unlock() + // Double-check under write lock — another goroutine may have populated. + if existing, ok := e.cache[key]; ok { + return existing, nil + } + if len(e.cache) >= e.max { + e.evictLowestVersionLocked() + } + e.cache[key] = newGCM + return newGCM, nil +} + +// evictLowestVersionLocked drops the entry with the lowest version +// across all rooms. Caller must hold e.mu for writing. +func (e *Encoder) evictLowestVersionLocked() { + var ( + victim encoderCacheKey + haveFirst bool + ) + for k := range e.cache { + if !haveFirst || k.version < victim.version { + victim = k + haveFirst = true + } + } + if haveFirst { + delete(e.cache, victim) + } +} +``` + +Add the missing imports to the existing import block: + +```go +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/rand" + "crypto/sha256" + "fmt" + "io" + "sync" + + "golang.org/x/crypto/hkdf" +) +``` + +(The `crypto/ecdh` import stays because legacy `Encode` still uses it.) + +Add the `EphemeralPublicKey` field tag change in the existing struct so the new scheme can omit it cleanly: + +```go +type EncryptedMessage struct { + Version int `json:"version"` + EphemeralPublicKey []byte `json:"ephemeralPublicKey,omitempty"` // legacy scheme; empty on the new scheme + Nonce []byte `json:"nonce"` + Ciphertext []byte `json:"ciphertext"` +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./pkg/roomcrypto/ -run TestEncoder_Encode_HappyPath -v` +Expected: PASS. + +- [ ] **Step 5: Run the full package to make sure legacy tests still pass** + +Run: `go test ./pkg/roomcrypto/ -v` +Expected: All tests pass (legacy `TestEncode*` + the one new test). + +- [ ] **Step 6: Commit** + +```bash +git add pkg/roomcrypto/roomcrypto.go pkg/roomcrypto/roomcrypto_test.go +git commit -m "feat(roomcrypto): add HKDF-only Encoder type alongside legacy Encode" +``` + +--- + +### Task 2: Encoder cache-hit test + +**Files:** +- Test: `pkg/roomcrypto/roomcrypto_test.go` + +We need to assert that two encodes for the same `(roomID, version)` re-use the cached cipher. Easiest signal: the cache map size after two calls equals one. + +- [ ] **Step 1: Add a test-only accessor in `pkg/roomcrypto/roomcrypto.go`** + +Append: + +```go +// cacheLen is exported only for tests in the same package. +func (e *Encoder) cacheLen() int { + e.mu.RLock() + defer e.mu.RUnlock() + return len(e.cache) +} +``` + +- [ ] **Step 2: Add the failing test** + +```go +func TestEncoder_Encode_CacheHit(t *testing.T) { + priv := bytes.Repeat([]byte{0xAB}, 32) + enc := NewEncoder() + + _, err := enc.Encode("room-1", "msg1", priv, 1) + require.NoError(t, err) + _, err = enc.Encode("room-1", "msg2", priv, 1) + require.NoError(t, err) + + assert.Equal(t, 1, enc.cacheLen(), "same (roomID, version) must not re-derive the AES key") +} + +func TestEncoder_Encode_DistinctVersionsCacheSeparately(t *testing.T) { + priv := bytes.Repeat([]byte{0xAB}, 32) + enc := NewEncoder() + + _, err := enc.Encode("room-1", "msg1", priv, 1) + require.NoError(t, err) + _, err = enc.Encode("room-1", "msg2", priv, 2) + require.NoError(t, err) + + assert.Equal(t, 2, enc.cacheLen(), "different versions must occupy distinct cache entries") +} +``` + +- [ ] **Step 3: Run the tests** + +Run: `go test ./pkg/roomcrypto/ -run TestEncoder_Encode_CacheHit -v && go test ./pkg/roomcrypto/ -run TestEncoder_Encode_DistinctVersions -v` +Expected: PASS (the implementation from Task 1 already handles caching). + +- [ ] **Step 4: Commit** + +```bash +git add pkg/roomcrypto/roomcrypto.go pkg/roomcrypto/roomcrypto_test.go +git commit -m "test(roomcrypto): assert Encoder caches AEAD per (roomID, version)" +``` + +--- + +### Task 3: Encoder cache-eviction test + +**Files:** +- Test: `pkg/roomcrypto/roomcrypto_test.go` + +- [ ] **Step 1: Add the failing test** + +```go +func TestEncoder_Encode_EvictsLowestVersion(t *testing.T) { + priv := bytes.Repeat([]byte{0x42}, 32) + enc := NewEncoder(WithMaxCacheEntries(2)) + + _, err := enc.Encode("room-A", "a", priv, 1) + require.NoError(t, err) + _, err = enc.Encode("room-A", "b", priv, 2) + require.NoError(t, err) + _, err = enc.Encode("room-A", "c", priv, 3) + require.NoError(t, err) + + assert.Equal(t, 2, enc.cacheLen(), "cache must not exceed max") + // Encoding for version 1 again must miss the cache (we evicted it), + // while versions 2 and 3 must hit. + prevLen := enc.cacheLen() + _, err = enc.Encode("room-A", "d", priv, 2) + require.NoError(t, err) + assert.Equal(t, prevLen, enc.cacheLen(), "version 2 must still be cached (hit)") + + _, err = enc.Encode("room-A", "e", priv, 1) + require.NoError(t, err) + assert.Equal(t, 2, enc.cacheLen(), "after re-inserting v=1, cache stays at max (now v=2 should have been evicted as lowest)") +} +``` + +- [ ] **Step 2: Run the test** + +Run: `go test ./pkg/roomcrypto/ -run TestEncoder_Encode_EvictsLowestVersion -v` +Expected: PASS (the Task 1 implementation already does lowest-version eviction). + +- [ ] **Step 3: Commit** + +```bash +git add pkg/roomcrypto/roomcrypto_test.go +git commit -m "test(roomcrypto): assert Encoder evicts lowest version when full" +``` + +--- + +### Task 4: Encoder rand-reader error path + +**Files:** +- Test: `pkg/roomcrypto/roomcrypto_test.go` + +- [ ] **Step 1: Add the failing test** + +```go +func TestEncoder_Encode_NonceReaderError(t *testing.T) { + priv := bytes.Repeat([]byte{0xAA}, 32) + enc := NewEncoder(WithRand(&failReader{})) + + got, err := enc.Encode("room-1", "hello", priv, 1) + require.Error(t, err) + assert.Contains(t, err.Error(), "generating nonce") + assert.Nil(t, got) +} + +func TestEncoder_Encode_InvalidKeyLength(t *testing.T) { + enc := NewEncoder() + got, err := enc.Encode("room-1", "hello", make([]byte, 31), 1) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be 32 bytes") + assert.Nil(t, got) +} +``` + +`failReader` already exists in `roomcrypto_test.go` from the legacy tests. + +- [ ] **Step 2: Run the tests** + +Run: `go test ./pkg/roomcrypto/ -run "TestEncoder_Encode_NonceReaderError|TestEncoder_Encode_InvalidKeyLength" -v` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add pkg/roomcrypto/roomcrypto_test.go +git commit -m "test(roomcrypto): cover Encoder error paths (nonce reader, key length)" +``` + +--- + +### Task 5: Round-trip test for Encoder (inline decrypt) + +**Files:** +- Test: `pkg/roomcrypto/roomcrypto_test.go` + +- [ ] **Step 1: Add the failing test** + +```go +func TestEncoder_Encode_RoundTrip(t *testing.T) { + cases := []struct { + name string + content string + }{ + {name: "non-empty", content: "hello, world"}, + {name: "empty", content: ""}, + {name: "unicode", content: "héllo 🌎"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + priv := bytes.Repeat([]byte{0x55}, 32) + enc := NewEncoder() + msg, err := enc.Encode("room-1", tc.content, priv, 3) + require.NoError(t, err) + require.NotNil(t, msg) + + // Re-derive the AES key with the same HKDF parameters and decrypt. + aesKey := make([]byte, 32) + r := hkdf.New(sha256.New, priv, nil, []byte("room-message-encryption-v2")) + _, err = io.ReadFull(r, aesKey) + require.NoError(t, err) + + block, err := aes.NewCipher(aesKey) + require.NoError(t, err) + gcm, err := cipher.NewGCM(block) + require.NoError(t, err) + + plaintext, err := gcm.Open(nil, msg.Nonce, msg.Ciphertext, nil) + require.NoError(t, err) + assert.Equal(t, tc.content, string(plaintext)) + }) + } +} +``` + +- [ ] **Step 2: Run the test** + +Run: `go test ./pkg/roomcrypto/ -run TestEncoder_Encode_RoundTrip -v` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add pkg/roomcrypto/roomcrypto_test.go +git commit -m "test(roomcrypto): round-trip Encoder output via inline HKDF/GCM" +``` + +--- + +### Task 6: Update `bench_test.go` to benchmark the Encoder + +**Files:** +- Modify: `pkg/roomcrypto/bench_test.go` + +- [ ] **Step 1: Append a new benchmark to `bench_test.go`** + +```go +// BenchmarkEncoder_Encode measures the proposed hot path: encoder cache +// hit + nonce read + AES-GCM seal. This is what broadcast-worker pays +// per message after migration. +func BenchmarkEncoder_Encode(b *testing.B) { + priv := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, priv); err != nil { + b.Fatal(err) + } + enc := NewEncoder() + + // Warm the cache so we measure the steady-state cost, not the + // one-time HKDF derivation. + if _, err := enc.Encode("room-1", "warm", priv, 1); err != nil { + b.Fatal(err) + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := enc.Encode("room-1", "hello, world — a typical short chat message", priv, 1); err != nil { + b.Fatal(err) + } + } +} +``` + +- [ ] **Step 2: Run the benchmark to confirm it builds and produces a number** + +Run: `go test -bench=BenchmarkEncoder_Encode -benchmem -benchtime=2s -run=^$ ./pkg/roomcrypto/` +Expected: Output line `BenchmarkEncoder_Encode-N ... ns/op ... allocs/op`. Verify allocs/op is small (1–3). + +- [ ] **Step 3: Commit** + +```bash +git add pkg/roomcrypto/bench_test.go +git commit -m "bench(roomcrypto): add BenchmarkEncoder_Encode for the new hot path" +``` + +--- + +### Task 7: Switch `broadcast-worker` to the new Encoder + +**Files:** +- Modify: `broadcast-worker/handler.go:210, 254` +- Modify: `broadcast-worker/handler.go` (handler struct + constructor) +- Modify: `broadcast-worker/main.go` +- Modify: `broadcast-worker/handler_test.go` + +- [ ] **Step 1: Read the handler's NewHandler signature and constructor wiring** + +Run: `grep -n "func NewHandler\|encoder\|encrypt " broadcast-worker/handler.go | head -20` + +Confirm the current field set, then add `encoder` as a new dependency. + +- [ ] **Step 2: Update `broadcast-worker/handler.go` — add encoder field** + +Add field on the handler struct: + +```go +type Handler struct { + // ... existing fields ... + encoder *roomcrypto.Encoder +} +``` + +Add it to `NewHandler(...)` parameters and the struct literal it constructs. The exact existing parameter list is fixed — append `encoder *roomcrypto.Encoder` as the last parameter to minimize churn. + +- [ ] **Step 3: Update both `roomcrypto.Encode(...)` call sites** + +In `encryptEditedContent` (around line 210): + +```go +encrypted, err := h.encoder.Encode(roomID, edited.NewContent, key.KeyPair.PrivateKey, key.Version) +``` + +In `publishChannelEvent` (around line 254): + +```go +encrypted, err := h.encoder.Encode(meta.ID, string(msgJSON), key.KeyPair.PrivateKey, key.Version) +``` + +Note the swap from `key.KeyPair.PublicKey` → `key.KeyPair.PrivateKey` and the added `roomID` first arg. + +- [ ] **Step 4: Update `broadcast-worker/main.go` — parse config and construct encoder** + +Add to the `config` struct: + +```go +RoomCryptoCacheSize int `env:"ROOM_CRYPTO_CACHE_SIZE" envDefault:"4096"` +``` + +In the `main` function dependency-construction block, before `NewHandler` is called: + +```go +encoder := roomcrypto.NewEncoder(roomcrypto.WithMaxCacheEntries(cfg.RoomCryptoCacheSize)) +``` + +Pass `encoder` as the last argument to `NewHandler(...)`. + +- [ ] **Step 5: Update `broadcast-worker/handler_test.go` — inject a real encoder** + +In every test that constructs a `Handler` via `NewHandler(...)`, append `roomcrypto.NewEncoder()` as the last argument. Search for `NewHandler(` and update each call site. + +If there is a shared test helper (e.g., `newTestHandler(t)` in `testhelpers_test.go`), update it once there and the call sites should be unaffected. + +- [ ] **Step 6: Run broadcast-worker tests** + +Run: `make test SERVICE=broadcast-worker` +Expected: All tests pass. + +If a handler test asserts on the published-event JSON, also assert that `encryptedMessage` no longer contains `ephemeralPublicKey`. Example assertion to add (or update): + +```go +// Confirm the new wire shape. +var payload map[string]json.RawMessage +require.NoError(t, json.Unmarshal(publishedEvt.EncryptedMessage, &payload)) +assert.Contains(t, payload, "version") +assert.Contains(t, payload, "nonce") +assert.Contains(t, payload, "ciphertext") +assert.NotContains(t, payload, "ephemeralPublicKey") +``` + +- [ ] **Step 7: Build broadcast-worker to confirm it compiles** + +Run: `make build SERVICE=broadcast-worker` +Expected: Build succeeds. + +- [ ] **Step 8: Commit** + +```bash +git add broadcast-worker/handler.go broadcast-worker/handler_test.go broadcast-worker/main.go +git commit -m "feat(broadcast-worker): use HKDF-only roomcrypto.Encoder" +``` + +--- + +### Task 8: Remove the legacy free `roomcrypto.Encode` + +Now that `broadcast-worker` no longer calls the legacy function, delete it and its associated tests. + +**Files:** +- Modify: `pkg/roomcrypto/roomcrypto.go` +- Modify: `pkg/roomcrypto/roomcrypto_test.go` + +- [ ] **Step 1: Delete the legacy `Encode` and `encode` functions from `pkg/roomcrypto/roomcrypto.go`** + +Remove every line of the legacy `Encode(content, roomPublicKey, version)` function and the internal `encode(content, roomPublicKey, version, randReader)` helper. Remove the `crypto/ecdh` import — it's no longer referenced. + +The file should now contain only: +- The `EncryptedMessage` struct (with `EphemeralPublicKey` still present but `omitempty`) +- The `Encoder` type, `encoderCacheKey`, `EncoderOption`, `WithMaxCacheEntries`, `WithRand`, `NewEncoder`, `(*Encoder).Encode`, `aeadFor`, `evictLowestVersionLocked`, `cacheLen` + +Keep `EphemeralPublicKey` on the struct (with `omitempty`) for one more PR so the Swift client's JSON parser doesn't need to handle the field disappearing; it will simply always read as empty. (This is the minimum compat surface; the field is deleted in a follow-up PR per spec future-work.) + +- [ ] **Step 2: Delete legacy tests in `pkg/roomcrypto/roomcrypto_test.go`** + +Remove the following test functions entirely: + +- `TestEncode` +- `TestEncode_RoundTrip` +- `TestEncode_NonDeterminism` +- `TestEncode_RandReaderErrors` +- `TestEncode_Version` + +Keep: +- `TestEncryptedMessage_JSONRoundTrip` (still valid for the struct shape) +- All `TestEncoder_*` tests added above +- `failReader` (used by Encoder tests) + +- [ ] **Step 3: Add a non-determinism test for the Encoder** + +```go +func TestEncoder_Encode_NonDeterminism(t *testing.T) { + priv := bytes.Repeat([]byte{0x33}, 32) + enc := NewEncoder() + + a, err := enc.Encode("room-1", "same content", priv, 1) + require.NoError(t, err) + b, err := enc.Encode("room-1", "same content", priv, 1) + require.NoError(t, err) + + assert.False(t, bytes.Equal(a.Nonce, b.Nonce), "nonces must differ") + assert.False(t, bytes.Equal(a.Ciphertext, b.Ciphertext), "ciphertexts must differ") +} +``` + +- [ ] **Step 4: Run the package tests** + +Run: `go test ./pkg/roomcrypto/ -v` +Expected: All tests pass. No references to `crypto/ecdh` remain. + +- [ ] **Step 5: Run the rest of the build** + +Run: `go build ./...` +Expected: Build succeeds (broadcast-worker no longer references the deleted function). + +- [ ] **Step 6: Commit** + +```bash +git add pkg/roomcrypto/roomcrypto.go pkg/roomcrypto/roomcrypto_test.go +git commit -m "refactor(roomcrypto): remove legacy ECIES Encode, drop crypto/ecdh dep" +``` + +--- + +### Task 9: Update integration test TS decrypt script + +**Files:** +- Modify: `pkg/roomcrypto/testdata/decrypt.ts` (if it exists; otherwise look in `pkg/roomcrypto/integration_test.go` for inline TS) +- Modify: `pkg/roomcrypto/integration_test.go` + +- [ ] **Step 1: Locate the TS decrypt source** + +Run: `ls pkg/roomcrypto/testdata/ 2>/dev/null; grep -n "decrypt\.ts\|tsx\|TextDecoder" pkg/roomcrypto/integration_test.go | head -20` + +Identify whether the TS decrypt source is a separate file under `testdata/` or embedded inline in the test. Update whichever applies. + +- [ ] **Step 2: Rewrite the TS decryptor to the new scheme** + +The new TS decryptor: + +```ts +// decrypt.ts — invoked by integration_test.go via tsx +import { createHash, createHmac } from 'node:crypto' + +type Payload = { + privateKey: string // base64 32-byte raw private scalar (high-entropy IKM) + message: { + version: number + nonce: string // base64 + ciphertext: string // base64 = content || 16-byte GCM tag + } +} + +function hkdfSha256(ikm: Buffer, info: Buffer, length: number): Buffer { + // Salt is empty per server: HKDF-Extract(salt=nil, IKM) = HMAC-SHA-256(0^32, IKM). + const prk = createHmac('sha256', Buffer.alloc(32)).update(ikm).digest() + const t = Buffer.alloc(0) + const out = Buffer.alloc(0) + let prev = Buffer.alloc(0) + const blocks: Buffer[] = [] + const n = Math.ceil(length / 32) + for (let i = 1; i <= n; i++) { + const h = createHmac('sha256', prk) + h.update(prev) + h.update(info) + h.update(Buffer.from([i])) + prev = h.digest() + blocks.push(prev) + } + return Buffer.concat(blocks).subarray(0, length) +} + +async function main() { + const raw = await new Promise((resolve, reject) => { + let chunks = '' + process.stdin.setEncoding('utf-8') + process.stdin.on('data', (c) => (chunks += c)) + process.stdin.on('end', () => resolve(chunks)) + process.stdin.on('error', reject) + }) + + const p = JSON.parse(raw) as Payload + const privateKey = Buffer.from(p.privateKey, 'base64') + if (privateKey.length !== 32) throw new Error(`expected 32-byte private key, got ${privateKey.length}`) + + const aesKey = hkdfSha256(privateKey, Buffer.from('room-message-encryption-v2'), 32) + const nonce = Buffer.from(p.message.nonce, 'base64') + const ciphertext = Buffer.from(p.message.ciphertext, 'base64') + + // Node's createDecipheriv splits ciphertext + auth tag manually. + const tag = ciphertext.subarray(ciphertext.length - 16) + const body = ciphertext.subarray(0, ciphertext.length - 16) + + const { createDecipheriv } = await import('node:crypto') + const decipher = createDecipheriv('aes-256-gcm', aesKey, nonce) + decipher.setAuthTag(tag) + const plaintext = Buffer.concat([decipher.update(body), decipher.final()]) + process.stdout.write(plaintext.toString('utf-8')) +} + +main().catch((err) => { + process.stderr.write(`${err.stack ?? err.message ?? err}\n`) + process.exit(1) +}) +``` + +- [ ] **Step 3: Update `integration_test.go` `decryptPayload` struct** + +```go +type decryptPayload struct { + PrivateKey string `json:"privateKey"` // base64(roomPrivKeyBytes) — 32 bytes + Message *EncryptedMessage `json:"message"` +} +``` + +Remove the `PublicKey` field — no longer used. + +- [ ] **Step 4: Update test bodies to use the new Encoder** + +In every place the integration test constructs a payload, replace any call to the legacy `Encode` with the new `Encoder`: + +```go +enc := NewEncoder() +msg, err := enc.Encode("room-integration", plaintext, priv, 1) +require.NoError(t, err) + +payload := decryptPayload{ + PrivateKey: base64.StdEncoding.EncodeToString(priv), + Message: msg, +} +``` + +Where `priv` is a fresh 32-byte buffer from `crypto/rand`, **not** a P-256 ECDH private key. The new scheme accepts any 32 bytes of high-entropy material as IKM; the integration test should produce one with `crypto/rand.Read`. + +- [ ] **Step 5: Run the integration test (requires Docker)** + +Run: `make test-integration SERVICE=roomcrypto` (or the equivalent target; check the Makefile for `pkg/` integration paths). + +If `make test-integration` does not have a `pkg/roomcrypto` target, run: + +`go test -tags=integration ./pkg/roomcrypto/...` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add pkg/roomcrypto/integration_test.go pkg/roomcrypto/testdata/ +git commit -m "test(roomcrypto): update integration test for HKDF-only scheme" +``` + +--- + +## Phase 2 — `room-service` keys-bootstrap RPC + +### Task 10: Add subject builders for the keys-bootstrap RPC + +**Files:** +- Modify: `pkg/subject/subject.go` +- Modify: `pkg/subject/subject_test.go` + +- [ ] **Step 1: Add the failing test** + +In `pkg/subject/subject_test.go`: + +```go +func TestRoomsKeysBootstrap(t *testing.T) { + assert.Equal(t, "chat.user.alice.request.rooms.keys", RoomsKeysBootstrap("alice")) +} + +func TestRoomsKeysBootstrapWildcard(t *testing.T) { + assert.Equal(t, "chat.user.*.request.rooms.keys", RoomsKeysBootstrapWildcard()) +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./pkg/subject/ -run "TestRoomsKeysBootstrap" -v` +Expected: FAIL — undefined functions. + +- [ ] **Step 3: Add the subject builders to `pkg/subject/subject.go`** + +Append to the user-scoped subject section (near `RoomsList`): + +```go +// RoomsKeysBootstrap is the per-user request subject for fetching all +// room private keys the user is currently subscribed to. Used by clients +// on (re)connect to bootstrap their key cache. +func RoomsKeysBootstrap(account string) string { + return fmt.Sprintf("chat.user.%s.request.rooms.keys", account) +} + +// RoomsKeysBootstrapWildcard is the subscribe pattern used by room-service. +func RoomsKeysBootstrapWildcard() string { + return "chat.user.*.request.rooms.keys" +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `go test ./pkg/subject/ -run "TestRoomsKeysBootstrap" -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add pkg/subject/subject.go pkg/subject/subject_test.go +git commit -m "feat(subject): add RoomsKeysBootstrap subject builders" +``` + +--- + +### Task 11: Add `RoomsKeysResponse` model types + +**Files:** +- Modify: `pkg/model/event.go` (or a more appropriate model file — search existing patterns) +- Modify: `pkg/model/model_test.go` + +- [ ] **Step 1: Add the failing round-trip test** + +In `pkg/model/model_test.go` (existing roundTrip helper): + +```go +func TestRoomsKeysResponse_RoundTrip(t *testing.T) { + roundTrip(t, RoomsKeysResponse{ + Keys: []RoomsKeysEntry{ + {RoomID: "r1", Version: 7, PrivateKey: []byte{1, 2, 3}}, + {RoomID: "r2", Version: 0, PrivateKey: []byte{4, 5, 6}}, + }, + }) +} +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +Run: `go test ./pkg/model/ -run TestRoomsKeysResponse_RoundTrip -v` +Expected: FAIL — undefined types. + +- [ ] **Step 3: Add the types** + +In an appropriate model file (likely `pkg/model/event.go` near `RoomKeyEvent`): + +```go +// RoomsKeysEntry is one entry in the RoomsKeysResponse — a single (roomId, +// version, privateKey) tuple for a room the caller is subscribed to. +type RoomsKeysEntry struct { + RoomID string `json:"roomId" bson:"roomId"` + Version int `json:"version" bson:"version"` + PrivateKey []byte `json:"privateKey" bson:"privateKey"` +} + +// RoomsKeysResponse is the response to RoomsKeysBootstrap — the full +// snapshot of (roomId, version, privateKey) tuples for every room the +// caller is currently subscribed to that has a key in Valkey. +type RoomsKeysResponse struct { + Keys []RoomsKeysEntry `json:"keys" bson:"keys"` +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `go test ./pkg/model/ -run TestRoomsKeysResponse_RoundTrip -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add pkg/model/event.go pkg/model/model_test.go +git commit -m "feat(model): add RoomsKeysResponse and RoomsKeysEntry types" +``` + +--- + +### Task 12: Implement `room-service` handler `natsListRoomKeys` + +**Files:** +- Modify: `room-service/handler.go` +- Modify: `room-service/handler_test.go` +- Modify: `room-service/store.go` (if a new store method is needed) + +- [ ] **Step 1: Inspect existing patterns** + +Run: `grep -n "natsListRooms\|natsRoomsInfoBatch\|SubscriptionStore\|ListSubscriptions" room-service/handler.go room-service/store.go | head -30` + +Identify how room-service today reads the caller's subscriptions (look for the existing `natsListRooms` handler). + +- [ ] **Step 2: Add the failing handler test** + +In `room-service/handler_test.go`, add a test for the new handler. Follow the exact pattern of the existing `natsListRooms` test (look for it and mirror the mock setup, request construction, and assertion style). + +Sketch: + +```go +func TestHandler_NatsListRoomKeys_HappyPath(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockStore(ctrl) + keyStore := NewMockRoomKeyStore(ctrl) + h := newTestHandler(t, store, keyStore) + + account := "alice" + subs := []model.Subscription{ + {RoomID: "r1", IsSubscribed: ptrBool(true)}, + {RoomID: "r2", IsSubscribed: ptrBool(true)}, + } + store.EXPECT(). + ListSubscriptionsByAccount(gomock.Any(), account). + Return(subs, nil) + keyStore.EXPECT(). + GetMany(gomock.Any(), []string{"r1", "r2"}). + Return(map[string]*roomkeystore.VersionedKeyPair{ + "r1": {Version: 7, KeyPair: roomkeystore.RoomKeyPair{PrivateKey: []byte{1, 2}}}, + "r2": {Version: 0, KeyPair: roomkeystore.RoomKeyPair{PrivateKey: []byte{3, 4}}}, + }, nil) + + respBytes, err := h.handleListRoomKeys(t.Context(), account) + require.NoError(t, err) + + var resp model.RoomsKeysResponse + require.NoError(t, json.Unmarshal(respBytes, &resp)) + assert.Len(t, resp.Keys, 2) + keysByRoom := map[string]model.RoomsKeysEntry{} + for _, k := range resp.Keys { + keysByRoom[k.RoomID] = k + } + assert.Equal(t, 7, keysByRoom["r1"].Version) + assert.Equal(t, []byte{1, 2}, keysByRoom["r1"].PrivateKey) +} +``` + +If `ListSubscriptionsByAccount` doesn't exist on the store, the closest existing method should be used. Search: + +`grep -n "ListSubscriptions\|FindSubscriptions" room-service/store.go` + +If a suitable method exists, use it. Otherwise this task includes adding it — see Step 3a below. + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `go test ./room-service/ -run TestHandler_NatsListRoomKeys -v` +Expected: FAIL — `undefined: handleListRoomKeys` (or unmapped expectations). + +- [ ] **Step 3a (only if no suitable store method exists): Add the store method** + +Add to `room-service/store.go`: + +```go +type Store interface { + // ... existing methods ... + ListSubscriptionsByAccount(ctx context.Context, account string) ([]model.Subscription, error) +} +``` + +Implement in `room-service/store_mongo.go`: + +```go +func (m *mongoStore) ListSubscriptionsByAccount(ctx context.Context, account string) ([]model.Subscription, error) { + cur, err := m.subscriptionsCol.Find(ctx, bson.M{"u.account": account, "isSubscribed": true}) + if err != nil { + return nil, fmt.Errorf("find subscriptions for account %s: %w", account, err) + } + defer cur.Close(ctx) + var out []model.Subscription + if err := cur.All(ctx, &out); err != nil { + return nil, fmt.Errorf("decode subscriptions: %w", err) + } + return out, nil +} +``` + +Then run `make generate SERVICE=room-service` to regenerate `mock_store_test.go`. + +- [ ] **Step 4: Implement `handleListRoomKeys` and `natsListRoomKeys` in `room-service/handler.go`** + +```go +func (h *Handler) natsListRoomKeys(m otelnats.Msg) { + ctx := wrappedCtx(m) + // Subject pattern: chat.user.{account}.request.rooms.keys → account at index 2. + // Mirrors the parts-split pattern used by natsGetRoom in this file. + parts := strings.Split(m.Msg.Subject, ".") + if len(parts) < 6 || parts[0] != "chat" || parts[1] != "user" { + natsutil.ReplyError(m.Msg, fmt.Errorf("invalid subject: %s", m.Msg.Subject)) + return + } + account := parts[2] + resp, err := h.handleListRoomKeys(ctx, account) + if err != nil { + slog.Error("list room keys failed", "error", err, "account", account) + natsutil.ReplyError(m.Msg, sanitizeError(err)) + return + } + if err := m.Msg.Respond(resp); err != nil { + slog.Error("failed to respond to message", "error", err) + } +} + +func (h *Handler) handleListRoomKeys(ctx context.Context, account string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + subs, err := h.store.ListSubscriptionsByAccount(ctx, account) + if err != nil { + return nil, fmt.Errorf("list subscriptions for %s: %w", account, err) + } + if len(subs) == 0 { + return json.Marshal(model.RoomsKeysResponse{Keys: []model.RoomsKeysEntry{}}) + } + + roomIDs := make([]string, 0, len(subs)) + for i := range subs { + roomIDs = append(roomIDs, subs[i].RoomID) + } + + keys, err := chunkedGetKeys(ctx, h.keyStore, roomIDs) + if err != nil { + return nil, fmt.Errorf("get room keys for %s: %w", account, err) + } + + out := make([]model.RoomsKeysEntry, 0, len(keys)) + for roomID, kp := range keys { + if kp == nil { + continue + } + out = append(out, model.RoomsKeysEntry{ + RoomID: roomID, + Version: kp.Version, + PrivateKey: kp.KeyPair.PrivateKey, + }) + } + return json.Marshal(model.RoomsKeysResponse{Keys: out}) +} +``` + +(The `strings.Split` pattern shown is the convention used by `natsGetRoom` in the same file. There is no shared `subject.AccountFromSubject` helper today.) + +- [ ] **Step 5: Register the handler in `RegisterCRUD`** + +Add a new line in `RegisterCRUD`: + +```go +if _, err := nc.QueueSubscribe(subject.RoomsKeysBootstrapWildcard(), queue, h.natsListRoomKeys); err != nil { + return fmt.Errorf("subscribe rooms keys bootstrap: %w", err) +} +``` + +- [ ] **Step 6: Run the handler test** + +Run: `make test SERVICE=room-service` +Expected: All tests pass. + +- [ ] **Step 7: Run the full build** + +Run: `go build ./...` +Expected: Build succeeds. + +- [ ] **Step 8: Commit** + +```bash +git add room-service/ +git commit -m "feat(room-service): add chat.user.{account}.request.rooms.keys RPC" +``` + +--- + +### Task 13: Integration test for the new RPC (optional but recommended) + +**Files:** +- Modify: `room-service/integration_test.go` + +- [ ] **Step 1: Add an integration test that exercises the full RPC** + +Pattern-match against the existing `RoomsInfoBatch` integration test in the same file. The new test should: + +1. Insert two subscriptions for account `alice` into Mongo via the real store +2. Insert two room keys into Valkey via the real keystore +3. Issue a `nc.Request(subject.RoomsKeysBootstrap("alice"), nil, 2*time.Second)` +4. Decode `model.RoomsKeysResponse` +5. Assert both rooms appear with their keys + +- [ ] **Step 2: Run the integration test** + +Run: `make test-integration SERVICE=room-service` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add room-service/integration_test.go +git commit -m "test(room-service): integration test for rooms keys bootstrap RPC" +``` + +--- + +## Phase 3 — chat-frontend crypto module + +### Task 14: Add `lib/roomcrypto` module — `b64decode` helper + +**Files:** +- Create: `chat-frontend/src/lib/roomcrypto/roomcrypto.ts` +- Create: `chat-frontend/src/lib/roomcrypto/roomcrypto.test.ts` +- Create: `chat-frontend/src/lib/roomcrypto/index.ts` + +- [ ] **Step 1: Write the failing test** + +`chat-frontend/src/lib/roomcrypto/roomcrypto.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { b64decode } from './roomcrypto' + +describe('b64decode', () => { + it('decodes a known base64 string', () => { + expect(Array.from(b64decode('aGVsbG8='))).toEqual([104, 101, 108, 108, 111]) + }) + + it('decodes an empty string to an empty Uint8Array', () => { + expect(b64decode('').length).toBe(0) + }) + + it('round-trips with btoa', () => { + const original = new Uint8Array([1, 2, 3, 250]) + const encoded = btoa(String.fromCharCode(...original)) + expect(Array.from(b64decode(encoded))).toEqual([1, 2, 3, 250]) + }) +}) +``` + +- [ ] **Step 2: Run the test to confirm failure** + +Run: `cd chat-frontend && npx vitest run src/lib/roomcrypto/` +Expected: FAIL — module not found. + +- [ ] **Step 3: Create the module** + +`chat-frontend/src/lib/roomcrypto/roomcrypto.ts`: + +```ts +/** + * Decode a standard-base64 string to a Uint8Array. + * + * Note: this is base64, not base64url. The server emits standard base64 + * via Go's encoding/json default for []byte fields (StdEncoding). + */ +export function b64decode(s: string): Uint8Array { + const binary = atob(s) + const out = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) + return out +} +``` + +`chat-frontend/src/lib/roomcrypto/index.ts`: + +```ts +export { b64decode, deriveAesKey, decryptRoomMessage } from './roomcrypto' +``` + +(The `deriveAesKey` and `decryptRoomMessage` exports won't exist yet — TypeScript will complain. That's fine; Task 15 adds them.) + +- [ ] **Step 4: Run the test** + +Run: `cd chat-frontend && npx vitest run src/lib/roomcrypto/` +Expected: `b64decode` tests pass. Index file may have a TS error referencing undefined names — that's expected; Task 15 fixes it. + +For now, temporarily simplify `index.ts` to only export what exists: + +```ts +export { b64decode } from './roomcrypto' +``` + +We'll expand it as we add functions. + +- [ ] **Step 5: Commit** + +```bash +git add chat-frontend/src/lib/roomcrypto/ +git commit -m "feat(chat-frontend): scaffold lib/roomcrypto with b64decode helper" +``` + +--- + +### Task 15: Add `deriveAesKey` using Web Crypto API + +**Files:** +- Modify: `chat-frontend/src/lib/roomcrypto/roomcrypto.ts` +- Modify: `chat-frontend/src/lib/roomcrypto/roomcrypto.test.ts` +- Modify: `chat-frontend/src/lib/roomcrypto/index.ts` + +- [ ] **Step 1: Add the failing test** + +```ts +import { deriveAesKey } from './roomcrypto' + +describe('deriveAesKey', () => { + it('returns a non-extractable AES-GCM CryptoKey usable for decrypt', async () => { + const priv = new Uint8Array(32) + priv.fill(0x42) + const key = await deriveAesKey(priv) + expect(key.type).toBe('secret') + expect(key.algorithm).toMatchObject({ name: 'AES-GCM', length: 256 }) + expect(key.usages).toEqual(['decrypt']) + expect(key.extractable).toBe(false) + }) + + it('produces a deterministic key for the same input', async () => { + // Cross-check: derive twice, exporting once via deriveBits with the same + // algorithm. We can't export an AES-GCM key directly when extractable=false, + // so instead test by encrypting+decrypting and verifying round-trip. + const priv = new Uint8Array(32) + priv.fill(0x07) + const k1 = await deriveAesKey(priv) + const k2 = await deriveAesKey(priv) + const nonce = new Uint8Array(12) + crypto.getRandomValues(nonce) + const plaintext = new TextEncoder().encode('hello') + // Derive a separate encryption-capable key from the same IKM for the test. + const encKey = await crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: new TextEncoder().encode('room-message-encryption-v2') }, + await crypto.subtle.importKey('raw', priv, 'HKDF', false, ['deriveKey']), + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt'], + ) + const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce, tagLength: 128 }, encKey, plaintext)) + // Decrypt with k1 and k2 — both must succeed. + const pt1 = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce, tagLength: 128 }, k1, ct)) + const pt2 = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce, tagLength: 128 }, k2, ct)) + expect(new TextDecoder().decode(pt1)).toBe('hello') + expect(new TextDecoder().decode(pt2)).toBe('hello') + }) + + it('rejects a private key of wrong length', async () => { + await expect(deriveAesKey(new Uint8Array(31))).rejects.toThrow(/32 bytes/) + }) +}) +``` + +- [ ] **Step 2: Run the test to confirm failure** + +Run: `cd chat-frontend && npx vitest run src/lib/roomcrypto/` +Expected: FAIL — `deriveAesKey is not defined`. + +- [ ] **Step 3: Implement `deriveAesKey`** + +Append to `chat-frontend/src/lib/roomcrypto/roomcrypto.ts`: + +```ts +const HKDF_INFO = new TextEncoder().encode('room-message-encryption-v2') +const HKDF_SALT = new Uint8Array(0) + +/** + * Derive an AES-256-GCM CryptoKey from a 32-byte room private key via + * HKDF-SHA-256 with empty salt and info "room-message-encryption-v2". + * + * The returned key is non-extractable and has the single usage 'decrypt'. + */ +export async function deriveAesKey(roomPrivateKey: Uint8Array): Promise { + if (roomPrivateKey.length !== 32) { + throw new Error(`room private key must be 32 bytes, got ${roomPrivateKey.length}`) + } + const ikm = await crypto.subtle.importKey('raw', roomPrivateKey, 'HKDF', false, ['deriveKey']) + return crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: HKDF_SALT, info: HKDF_INFO }, + ikm, + { name: 'AES-GCM', length: 256 }, + false, + ['decrypt'], + ) +} +``` + +Update `chat-frontend/src/lib/roomcrypto/index.ts`: + +```ts +export { b64decode, deriveAesKey } from './roomcrypto' +``` + +- [ ] **Step 4: Run the tests** + +Run: `cd chat-frontend && npx vitest run src/lib/roomcrypto/` +Expected: All three `deriveAesKey` tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add chat-frontend/src/lib/roomcrypto/ +git commit -m "feat(chat-frontend): add deriveAesKey via Web Crypto HKDF-SHA-256" +``` + +--- + +### Task 16: Add `decryptRoomMessage` + +**Files:** +- Modify: `chat-frontend/src/lib/roomcrypto/roomcrypto.ts` +- Modify: `chat-frontend/src/lib/roomcrypto/roomcrypto.test.ts` +- Modify: `chat-frontend/src/lib/roomcrypto/index.ts` + +- [ ] **Step 1: Add the failing tests** + +```ts +import { decryptRoomMessage } from './roomcrypto' + +describe('decryptRoomMessage', () => { + it('decrypts ciphertext produced via the matching encrypt path', async () => { + const priv = new Uint8Array(32) + priv.fill(0x99) + const aesKey = await deriveAesKey(priv) + + // Build a matching encrypt key (extractable=false but with encrypt usage). + const encKey = await crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: new TextEncoder().encode('room-message-encryption-v2') }, + await crypto.subtle.importKey('raw', priv, 'HKDF', false, ['deriveKey']), + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt'], + ) + + const nonce = new Uint8Array(12) + crypto.getRandomValues(nonce) + const plaintext = new TextEncoder().encode('héllo 🌎') + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce, tagLength: 128 }, encKey, plaintext), + ) + + const got = await decryptRoomMessage(ciphertext, nonce, aesKey) + expect(got).toBe('héllo 🌎') + }) + + it('throws on tag mismatch', async () => { + const priv = new Uint8Array(32) + priv.fill(0x11) + const aesKey = await deriveAesKey(priv) + const nonce = new Uint8Array(12) + const bogusCiphertext = new Uint8Array(32) // all-zero bytes, will fail tag verification + await expect(decryptRoomMessage(bogusCiphertext, nonce, aesKey)).rejects.toBeDefined() + }) +}) +``` + +- [ ] **Step 2: Run the tests to confirm failure** + +Run: `cd chat-frontend && npx vitest run src/lib/roomcrypto/` +Expected: FAIL — `decryptRoomMessage is not defined`. + +- [ ] **Step 3: Implement `decryptRoomMessage`** + +Append to `chat-frontend/src/lib/roomcrypto/roomcrypto.ts`: + +```ts +/** + * Decrypt a server-produced {nonce, ciphertext} pair using the AES key + * derived via deriveAesKey. The ciphertext is body || 16-byte GCM tag, + * matching Go's cipher.AEAD.Seal output. + */ +export async function decryptRoomMessage( + ciphertext: Uint8Array, + nonce: Uint8Array, + aesKey: CryptoKey, +): Promise { + if (nonce.length !== 12) { + throw new Error(`nonce must be 12 bytes, got ${nonce.length}`) + } + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: nonce, tagLength: 128 }, + aesKey, + ciphertext, + ) + return new TextDecoder('utf-8').decode(plaintext) +} +``` + +Update `chat-frontend/src/lib/roomcrypto/index.ts`: + +```ts +export { b64decode, deriveAesKey, decryptRoomMessage } from './roomcrypto' +``` + +- [ ] **Step 4: Run the tests** + +Run: `cd chat-frontend && npx vitest run src/lib/roomcrypto/` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add chat-frontend/src/lib/roomcrypto/ +git commit -m "feat(chat-frontend): add decryptRoomMessage via Web Crypto AES-GCM" +``` + +--- + +### Task 17: Cross-language fixture — Go encode → TS decode + +**Files:** +- Create: `chat-frontend/scripts/gen-crypto-fixtures.go` +- Create: `chat-frontend/test/fixtures/encrypted-message.json` +- Modify: `chat-frontend/src/lib/roomcrypto/roomcrypto.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `chat-frontend/src/lib/roomcrypto/roomcrypto.test.ts`: + +```ts +import fixture from '../../../test/fixtures/encrypted-message.json' + +describe('cross-language fixture', () => { + it('decrypts a fixture produced by the Go server encoder', async () => { + const priv = b64decode(fixture.privateKey) + const nonce = b64decode(fixture.message.nonce) + const ciphertext = b64decode(fixture.message.ciphertext) + + const aesKey = await deriveAesKey(priv) + const plaintext = await decryptRoomMessage(ciphertext, nonce, aesKey) + expect(plaintext).toBe(fixture.plaintext) + }) +}) +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +Run: `cd chat-frontend && npx vitest run src/lib/roomcrypto/` +Expected: FAIL — fixture file not found. + +- [ ] **Step 3: Create the Go fixture-generator program** + +`chat-frontend/scripts/gen-crypto-fixtures.go`: + +```go +//go:build ignore + +// gen-crypto-fixtures generates a known-plaintext encrypted message for +// chat-frontend's lib/roomcrypto round-trip tests. Run with: +// +// go run chat-frontend/scripts/gen-crypto-fixtures.go > chat-frontend/test/fixtures/encrypted-message.json +// +// Commit the output. The fixture exists to lock the cross-language wire +// format: any change in the server encoder's HKDF parameters or wire +// shape MUST update this fixture together with the corresponding TS +// decoder. +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + + "github.com/hmchangw/chat/pkg/roomcrypto" +) + +func main() { + // Deterministic private key so the fixture is stable across runs. + priv := make([]byte, 32) + for i := range priv { + priv[i] = byte(i + 1) + } + const plaintext = "fixture plaintext — encoded by the Go server, decoded by chat-frontend" + const version = 1 + const roomID = "fixture-room" + + // Construct an Encoder with a fixed-nonce reader so the ciphertext is + // reproducible. Twelve-byte zero nonce is fine for a fixture (it's a + // known-key test, not real traffic). + enc := roomcrypto.NewEncoder(roomcrypto.WithRand(zeroReader{})) + msg, err := enc.Encode(roomID, plaintext, priv, version) + if err != nil { + fmt.Fprintln(os.Stderr, "encode:", err) + os.Exit(1) + } + + out := struct { + PrivateKey string `json:"privateKey"` + Plaintext string `json:"plaintext"` + Message *roomcrypto.EncryptedMessage `json:"message"` + }{ + PrivateKey: base64.StdEncoding.EncodeToString(priv), + Plaintext: plaintext, + Message: msg, + } + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, "marshal:", err) + os.Exit(1) + } + fmt.Println(string(data)) +} + +type zeroReader struct{} + +func (zeroReader) Read(p []byte) (int, error) { + for i := range p { + p[i] = 0 + } + return len(p), nil +} +``` + +- [ ] **Step 4: Run the generator** + +Run: `go run chat-frontend/scripts/gen-crypto-fixtures.go > chat-frontend/test/fixtures/encrypted-message.json` + +Verify the output file exists and contains base64-encoded fields. + +- [ ] **Step 5: Configure Vitest to allow JSON imports if needed** + +If the test fails with "cannot find module" for the JSON import, check `chat-frontend/tsconfig.json` for `resolveJsonModule: true`. Add it if absent. + +- [ ] **Step 6: Run the test** + +Run: `cd chat-frontend && npx vitest run src/lib/roomcrypto/` +Expected: All tests pass, including the new cross-language one. + +- [ ] **Step 7: Commit** + +```bash +git add chat-frontend/scripts/gen-crypto-fixtures.go chat-frontend/test/fixtures/ chat-frontend/src/lib/roomcrypto/roomcrypto.test.ts +git commit -m "test(chat-frontend): cross-language fixture for Go encode → TS decode" +``` + +--- + +## Phase 4 — chat-frontend API ops + +### Task 18: Add subject builders for room-key subjects + +**Files:** +- Modify: `chat-frontend/src/api/_transport/subjects.ts` +- Modify: `chat-frontend/src/api/_transport/subjects.test.js` + +- [ ] **Step 1: Add the failing tests** + +In `subjects.test.js`: + +```js +import { userRoomKey, roomsKeysBootstrap } from './subjects' + +describe('userRoomKey', () => { + it('builds the per-user room-key event subject', () => { + expect(userRoomKey('alice')).toBe('chat.user.alice.event.room.key') + }) +}) + +describe('roomsKeysBootstrap', () => { + it('builds the per-user keys-bootstrap RPC subject', () => { + expect(roomsKeysBootstrap('alice')).toBe('chat.user.alice.request.rooms.keys') + }) +}) +``` + +- [ ] **Step 2: Run the tests to verify failure** + +Run: `cd chat-frontend && npx vitest run src/api/_transport/subjects.test.js` +Expected: FAIL — undefined exports. + +- [ ] **Step 3: Add the builders to `subjects.ts`** + +Append: + +```ts +export function userRoomKey(account: string): string { + return `chat.user.${account}.event.room.key` +} + +export function roomsKeysBootstrap(account: string): string { + return `chat.user.${account}.request.rooms.keys` +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `cd chat-frontend && npx vitest run src/api/_transport/subjects.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add chat-frontend/src/api/_transport/subjects.ts chat-frontend/src/api/_transport/subjects.test.js +git commit -m "feat(chat-frontend/api): subject builders for room-key events and bootstrap RPC" +``` + +--- + +### Task 19: Add wire types `RoomKeyEvent`, `RoomKeysEntry`, `RoomKeysResponse` + +**Files:** +- Modify: `chat-frontend/src/api/types.ts` + +- [ ] **Step 1: Append to `api/types.ts`** + +```ts +/** + * Mirrors pkg/model.RoomKeyEvent — payload of + * chat.user.{account}.event.room.key. PrivateKey is base64-encoded on + * the wire (Go's encoding/json default for []byte). PublicKey is + * omitted from the client wire payload. + */ +export interface RoomKeyEvent { + roomId: string + version: number + privateKey: string // base64 + timestamp: number +} + +/** One entry in RoomKeysResponse — see pkg/model.RoomsKeysEntry. */ +export interface RoomKeysEntry { + roomId: string + version: number + privateKey: string // base64 +} + +/** Response to roomsKeysBootstrap RPC — see pkg/model.RoomsKeysResponse. */ +export interface RoomKeysResponse { + keys: RoomKeysEntry[] +} +``` + +- [ ] **Step 2: Re-export from the barrel** + +`chat-frontend/src/api/index.ts`: add `RoomKeyEvent`, `RoomKeysEntry`, `RoomKeysResponse` to the type re-exports. + +- [ ] **Step 3: Typecheck** + +Run: `cd chat-frontend && npm run typecheck` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add chat-frontend/src/api/types.ts chat-frontend/src/api/index.ts +git commit -m "feat(chat-frontend/api): add RoomKeyEvent + RoomKeys{Entry,Response} types" +``` + +--- + +### Task 20: New API op — `subscribeToRoomKeyEvents` + +**Files:** +- Create: `chat-frontend/src/api/subscribeToRoomKeyEvents/index.ts` +- Create: `chat-frontend/src/api/subscribeToRoomKeyEvents/index.test.ts` +- Modify: `chat-frontend/src/api/index.ts` + +- [ ] **Step 1: Write the failing test** + +`chat-frontend/src/api/subscribeToRoomKeyEvents/index.test.ts`: + +```ts +import { describe, it, expect, vi } from 'vitest' +import { subscribeToRoomKeyEvents } from './index' + +describe('subscribeToRoomKeyEvents', () => { + it('subscribes to chat.user.{account}.event.room.key with the given callback', () => { + const subscribe = vi.fn().mockReturnValue({ unsubscribe: vi.fn() }) + const nats = { subscribe, user: { account: 'alice', id: '' }, request: vi.fn(), publish: vi.fn(), requestWithAsyncResult: vi.fn(), connected: true, error: null } + const cb = vi.fn() + + const sub = subscribeToRoomKeyEvents(nats as never, cb) + + expect(subscribe).toHaveBeenCalledWith('chat.user.alice.event.room.key', cb) + expect(sub).toBeDefined() + }) +}) +``` + +- [ ] **Step 2: Run the test** + +Run: `cd chat-frontend && npx vitest run src/api/subscribeToRoomKeyEvents/` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the op** + +`chat-frontend/src/api/subscribeToRoomKeyEvents/index.ts`: + +```ts +import { userRoomKey } from '../_transport/subjects' +import type { Nats, NatsSubscription, SubscriptionCallback } from '../types' + +/** Subscribe to the calling user's room-key event stream. */ +export function subscribeToRoomKeyEvents( + { subscribe, user }: Pick, + callback: SubscriptionCallback, +): NatsSubscription { + return subscribe(userRoomKey(user.account), callback) +} +``` + +Add to `chat-frontend/src/api/index.ts`: + +```ts +export { subscribeToRoomKeyEvents } from './subscribeToRoomKeyEvents' +``` + +- [ ] **Step 4: Run the test** + +Run: `cd chat-frontend && npx vitest run src/api/subscribeToRoomKeyEvents/` +Expected: PASS. + +- [ ] **Step 5: Typecheck** + +Run: `cd chat-frontend && npm run typecheck` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add chat-frontend/src/api/subscribeToRoomKeyEvents/ chat-frontend/src/api/index.ts +git commit -m "feat(chat-frontend/api): subscribeToRoomKeyEvents" +``` + +--- + +### Task 21: New API op — `fetchRoomKeysBootstrap` + +**Files:** +- Create: `chat-frontend/src/api/fetchRoomKeysBootstrap/index.ts` +- Create: `chat-frontend/src/api/fetchRoomKeysBootstrap/index.test.ts` +- Modify: `chat-frontend/src/api/index.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect, vi } from 'vitest' +import { fetchRoomKeysBootstrap } from './index' + +describe('fetchRoomKeysBootstrap', () => { + it('issues a request on chat.user.{account}.request.rooms.keys with an empty payload and returns the parsed RoomKeysResponse', async () => { + const request = vi.fn().mockResolvedValue({ keys: [{ roomId: 'r1', version: 7, privateKey: 'AAAA' }] }) + const nats = { request, user: { account: 'alice', id: '' }, subscribe: vi.fn(), publish: vi.fn(), requestWithAsyncResult: vi.fn(), connected: true, error: null } + + const got = await fetchRoomKeysBootstrap(nats as never) + + expect(request).toHaveBeenCalledWith('chat.user.alice.request.rooms.keys', {}) + expect(got).toEqual({ keys: [{ roomId: 'r1', version: 7, privateKey: 'AAAA' }] }) + }) +}) +``` + +- [ ] **Step 2: Run the test** + +Run: `cd chat-frontend && npx vitest run src/api/fetchRoomKeysBootstrap/` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the op** + +```ts +// chat-frontend/src/api/fetchRoomKeysBootstrap/index.ts +import { roomsKeysBootstrap } from '../_transport/subjects' +import type { Nats, RoomKeysResponse } from '../types' + +/** Fetch the full snapshot of (roomId, version, privateKey) for every + * room the caller is subscribed to. Call once on (re)connect. */ +export function fetchRoomKeysBootstrap( + { request, user }: Pick, +): Promise { + return request(roomsKeysBootstrap(user.account), {}) +} +``` + +Add to `chat-frontend/src/api/index.ts`: + +```ts +export { fetchRoomKeysBootstrap } from './fetchRoomKeysBootstrap' +``` + +- [ ] **Step 4: Run the test and typecheck** + +Run: `cd chat-frontend && npx vitest run src/api/fetchRoomKeysBootstrap/ && npm run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add chat-frontend/src/api/fetchRoomKeysBootstrap/ chat-frontend/src/api/index.ts +git commit -m "feat(chat-frontend/api): fetchRoomKeysBootstrap RPC wrapper" +``` + +--- + +## Phase 5 — chat-frontend `RoomKeysContext` + +### Task 22: `RoomKeysContext` reducer + +**Files:** +- Create: `chat-frontend/src/context/RoomKeysContext/reducer.ts` +- Create: `chat-frontend/src/context/RoomKeysContext/reducer.test.ts` + +- [ ] **Step 1: Write the failing tests** + +`chat-frontend/src/context/RoomKeysContext/reducer.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { roomKeysReducer, initialRoomKeysState, type RoomKeysState } from './reducer' + +const seed = (): RoomKeysState => ({ ...initialRoomKeysState, byRoom: { ...initialRoomKeysState.byRoom } }) + +describe('roomKeysReducer', () => { + it('BOOTSTRAP_LOADED merges all entries by (roomId, version)', () => { + const next = roomKeysReducer(seed(), { + type: 'BOOTSTRAP_LOADED', + keys: [ + { roomId: 'r1', version: 1, privateKey: new Uint8Array([1, 2, 3]) }, + { roomId: 'r2', version: 5, privateKey: new Uint8Array([9]) }, + ], + }) + expect(next.bootstrapped).toBe(true) + expect(next.byRoom.r1[1].privateKey).toEqual(new Uint8Array([1, 2, 3])) + expect(next.byRoom.r2[5].privateKey).toEqual(new Uint8Array([9])) + }) + + it('KEY_RECEIVED inserts a single (roomId, version) entry', () => { + const next = roomKeysReducer(seed(), { + type: 'KEY_RECEIVED', + roomId: 'r1', + version: 2, + privateKey: new Uint8Array([7]), + }) + expect(next.byRoom.r1[2].privateKey).toEqual(new Uint8Array([7])) + }) + + it('KEY_RECEIVED is idempotent — same input does not duplicate', () => { + const action = { + type: 'KEY_RECEIVED' as const, + roomId: 'r1', + version: 2, + privateKey: new Uint8Array([7]), + } + const once = roomKeysReducer(seed(), action) + const twice = roomKeysReducer(once, action) + expect(Object.keys(twice.byRoom.r1)).toEqual(['2']) + }) + + it('KEY_RECEIVED keeps at most MAX_VERSIONS_PER_ROOM newest versions', () => { + let s = seed() + // Insert versions 1..5 — MAX_VERSIONS_PER_ROOM is 2, so only 4 and 5 should remain. + for (let v = 1; v <= 5; v++) { + s = roomKeysReducer(s, { + type: 'KEY_RECEIVED', + roomId: 'r1', + version: v, + privateKey: new Uint8Array([v]), + }) + } + const versions = Object.keys(s.byRoom.r1).map(Number).sort((a, b) => a - b) + expect(versions).toEqual([4, 5]) + }) + + it('CLEAR_KEYS resets state', () => { + const populated: RoomKeysState = { + bootstrapped: true, + byRoom: { r1: { 1: { privateKey: new Uint8Array([1]) } } }, + } + const next = roomKeysReducer(populated, { type: 'CLEAR_KEYS' }) + expect(next).toEqual(initialRoomKeysState) + }) +}) +``` + +- [ ] **Step 2: Run the tests** + +Run: `cd chat-frontend && npx vitest run src/context/RoomKeysContext/` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the reducer** + +`chat-frontend/src/context/RoomKeysContext/reducer.ts`: + +```ts +/** Maximum stored versions per room. Matches Valkey's previous-key grace + * slot (one previous in addition to current). */ +export const MAX_VERSIONS_PER_ROOM = 2 + +export type StoredKey = { + privateKey: Uint8Array +} + +export type RoomKeysState = { + byRoom: Record> + bootstrapped: boolean +} + +export const initialRoomKeysState: RoomKeysState = { + byRoom: {}, + bootstrapped: false, +} + +export type RoomKeysAction = + | { + type: 'BOOTSTRAP_LOADED' + keys: Array<{ roomId: string; version: number; privateKey: Uint8Array }> + } + | { + type: 'KEY_RECEIVED' + roomId: string + version: number + privateKey: Uint8Array + } + | { type: 'CLEAR_KEYS' } + +export function roomKeysReducer(state: RoomKeysState, action: RoomKeysAction): RoomKeysState { + switch (action.type) { + case 'BOOTSTRAP_LOADED': { + const byRoom: Record> = { ...state.byRoom } + for (const k of action.keys) { + const room = { ...(byRoom[k.roomId] ?? {}) } + room[k.version] = { privateKey: k.privateKey } + byRoom[k.roomId] = trimVersions(room) + } + return { ...state, byRoom, bootstrapped: true } + } + case 'KEY_RECEIVED': { + const existing = state.byRoom[action.roomId]?.[action.version] + if (existing && bytesEqual(existing.privateKey, action.privateKey)) { + return state // idempotent no-op + } + const room = { ...(state.byRoom[action.roomId] ?? {}) } + room[action.version] = { privateKey: action.privateKey } + return { + ...state, + byRoom: { ...state.byRoom, [action.roomId]: trimVersions(room) }, + } + } + case 'CLEAR_KEYS': + return initialRoomKeysState + default: + return state + } +} + +function trimVersions(room: Record): Record { + const versions = Object.keys(room).map(Number).sort((a, b) => b - a) + if (versions.length <= MAX_VERSIONS_PER_ROOM) return room + const out: Record = {} + for (const v of versions.slice(0, MAX_VERSIONS_PER_ROOM)) { + out[v] = room[v] + } + return out +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false + return true +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `cd chat-frontend && npx vitest run src/context/RoomKeysContext/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add chat-frontend/src/context/RoomKeysContext/ +git commit -m "feat(chat-frontend): RoomKeysContext reducer with idempotent key insert" +``` + +--- + +### Task 23: `RoomKeysContext` provider + `useRoomKeys` + +**Files:** +- Create: `chat-frontend/src/context/RoomKeysContext/RoomKeysContext.tsx` +- Create: `chat-frontend/src/context/RoomKeysContext/RoomKeysContext.test.tsx` +- Create: `chat-frontend/src/context/RoomKeysContext/index.tsx` + +- [ ] **Step 1: Write the failing test** + +`chat-frontend/src/context/RoomKeysContext/RoomKeysContext.test.tsx`: + +```tsx +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, waitFor, act } from '@testing-library/react' +import { RoomKeysProvider, useRoomKeys } from './RoomKeysContext' + +vi.mock('@/api', () => ({ + fetchRoomKeysBootstrap: vi.fn(), + subscribeToRoomKeyEvents: vi.fn(), +})) + +vi.mock('@/context/NatsContext', () => ({ + useNats: () => ({ + user: { account: 'alice', id: 'u1' }, + request: vi.fn(), + subscribe: vi.fn(), + publish: vi.fn(), + requestWithAsyncResult: vi.fn(), + connected: true, + error: null, + }), +})) + +import { fetchRoomKeysBootstrap, subscribeToRoomKeyEvents } from '@/api' + +let lastDecryptHook: ReturnType | null = null + +function Probe() { + lastDecryptHook = useRoomKeys() + return null +} + +describe('RoomKeysProvider', () => { + beforeEach(() => { + lastDecryptHook = null + vi.mocked(fetchRoomKeysBootstrap).mockReset() + vi.mocked(subscribeToRoomKeyEvents).mockReset() + }) + + it('calls fetchRoomKeysBootstrap on mount and seeds state', async () => { + vi.mocked(fetchRoomKeysBootstrap).mockResolvedValue({ + keys: [{ roomId: 'r1', version: 1, privateKey: btoa(String.fromCharCode(...new Uint8Array(32).fill(7))) }], + }) + const unsub = { unsubscribe: vi.fn() } + vi.mocked(subscribeToRoomKeyEvents).mockReturnValue(unsub) + + render( + + + , + ) + + await waitFor(() => { + expect(fetchRoomKeysBootstrap).toHaveBeenCalled() + }) + + await waitFor(() => { + expect(lastDecryptHook?.hasKey('r1', 1)).toBe(true) + }) + }) + + it('dispatches KEY_RECEIVED for each live event', async () => { + vi.mocked(fetchRoomKeysBootstrap).mockResolvedValue({ keys: [] }) + let savedCb: ((evt: unknown) => void) | undefined + vi.mocked(subscribeToRoomKeyEvents).mockImplementation((_n, cb) => { + savedCb = cb as never + return { unsubscribe: vi.fn() } + }) + + render( + + + , + ) + + await waitFor(() => expect(savedCb).toBeDefined()) + + act(() => { + savedCb!({ + roomId: 'r2', + version: 3, + privateKey: btoa(String.fromCharCode(...new Uint8Array(32).fill(1))), + timestamp: Date.now(), + }) + }) + + await waitFor(() => expect(lastDecryptHook?.hasKey('r2', 3)).toBe(true)) + }) + + it('unsubscribes and clears state on unmount', async () => { + vi.mocked(fetchRoomKeysBootstrap).mockResolvedValue({ keys: [] }) + const unsub = { unsubscribe: vi.fn() } + vi.mocked(subscribeToRoomKeyEvents).mockReturnValue(unsub) + + const { unmount } = render( + + + , + ) + unmount() + expect(unsub.unsubscribe).toHaveBeenCalled() + }) +}) +``` + +- [ ] **Step 2: Run the test** + +Run: `cd chat-frontend && npx vitest run src/context/RoomKeysContext/RoomKeysContext.test.tsx` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the provider** + +`chat-frontend/src/context/RoomKeysContext/RoomKeysContext.tsx`: + +```tsx +import { createContext, useCallback, useContext, useEffect, useReducer, useRef } from 'react' +import { fetchRoomKeysBootstrap, subscribeToRoomKeyEvents } from '@/api' +import type { RoomKeyEvent } from '@/api' +import { useNats } from '@/context/NatsContext' +import { b64decode, deriveAesKey, decryptRoomMessage } from '@/lib/roomcrypto' +import { initialRoomKeysState, roomKeysReducer } from './reducer' + +type DecryptInput = { + roomId: string + version: number + nonceB64: string + ciphertextB64: string +} + +type RoomKeysContextValue = { + hasKey(roomId: string, version: number): boolean + /** Returns null if the key is not (yet) known for that (roomId, version), + * or if decryption fails. */ + decrypt(input: DecryptInput): Promise +} + +const RoomKeysContext = createContext(null) + +export function useRoomKeys(): RoomKeysContextValue { + const ctx = useContext(RoomKeysContext) + if (!ctx) throw new Error('useRoomKeys called outside RoomKeysProvider') + return ctx +} + +export function RoomKeysProvider({ children }: { children: React.ReactNode }) { + const [state, dispatch] = useReducer(roomKeysReducer, initialRoomKeysState) + const nats = useNats() + + // CryptoKey cache lives in a ref — derived lazily, not React state. + // Keyed by `${roomId}|${version}`. + const aesKeyCacheRef = useRef>>(new Map()) + const stateRef = useRef(state) + stateRef.current = state + + useEffect(() => { + if (!nats.user) return + let cancelled = false + + fetchRoomKeysBootstrap(nats) + .then((resp) => { + if (cancelled) return + dispatch({ + type: 'BOOTSTRAP_LOADED', + keys: resp.keys.map((k) => ({ + roomId: k.roomId, + version: k.version, + privateKey: b64decode(k.privateKey), + })), + }) + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.error('roomKeysBootstrap failed:', err) + }) + + const sub = subscribeToRoomKeyEvents(nats, (raw) => { + const evt = raw as RoomKeyEvent + if (!evt || typeof evt.roomId !== 'string' || typeof evt.version !== 'number' || typeof evt.privateKey !== 'string') return + dispatch({ + type: 'KEY_RECEIVED', + roomId: evt.roomId, + version: evt.version, + privateKey: b64decode(evt.privateKey), + }) + }) + + return () => { + cancelled = true + sub.unsubscribe() + aesKeyCacheRef.current.clear() + dispatch({ type: 'CLEAR_KEYS' }) + } + // user identity is stable for the session — see useRoomSubscriptions for prior art. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nats.user]) + + const hasKey = useCallback((roomId: string, version: number) => { + return !!stateRef.current.byRoom[roomId]?.[version] + }, []) + + const decrypt = useCallback(async ({ roomId, version, nonceB64, ciphertextB64 }: DecryptInput): Promise => { + const entry = stateRef.current.byRoom[roomId]?.[version] + if (!entry) return null + + const cacheKey = `${roomId}|${version}` + let pending = aesKeyCacheRef.current.get(cacheKey) + if (!pending) { + pending = deriveAesKey(entry.privateKey) + aesKeyCacheRef.current.set(cacheKey, pending) + } + try { + const aesKey = await pending + return await decryptRoomMessage(b64decode(ciphertextB64), b64decode(nonceB64), aesKey) + } catch (err) { + // eslint-disable-next-line no-console + console.warn('roomKeysContext.decrypt failed:', err) + return null + } + }, []) + + const value: RoomKeysContextValue = { hasKey, decrypt } + + return {children} +} +``` + +`chat-frontend/src/context/RoomKeysContext/index.tsx`: + +```tsx +export { RoomKeysProvider, useRoomKeys } from './RoomKeysContext' +``` + +- [ ] **Step 4: Run the tests** + +Run: `cd chat-frontend && npx vitest run src/context/RoomKeysContext/` +Expected: PASS. + +- [ ] **Step 5: Typecheck** + +Run: `cd chat-frontend && npm run typecheck` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add chat-frontend/src/context/RoomKeysContext/ +git commit -m "feat(chat-frontend): RoomKeysProvider with bootstrap + live subscription" +``` + +--- + +### Task 24: Wire `RoomKeysProvider` into `App.jsx` + +**Files:** +- Modify: `chat-frontend/src/App.jsx` + +- [ ] **Step 1: Read the existing provider tree** + +Run: `grep -n "Provider\| + + + {/* ...existing tree... */} + + + +``` + +- [ ] **Step 3: Build + smoke** + +Run: `cd chat-frontend && npm run typecheck && npm test` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add chat-frontend/src/App.jsx +git commit -m "feat(chat-frontend): mount RoomKeysProvider in app tree" +``` + +--- + +## Phase 6 — chat-frontend RoomEvents integration + +### Task 25: Plumb `decrypt` from `RoomKeysContext` into `useRoomSubscriptions` + +**Files:** +- Modify: `chat-frontend/src/context/RoomEventsContext/RoomEventsContext.tsx` +- Modify: `chat-frontend/src/context/RoomEventsContext/useRoomSubscriptions.js` + +- [ ] **Step 1: Read the current provider and useRoomSubscriptions signature** + +Run: `grep -n "useRoomSubscriptions\|useRoomKeys" chat-frontend/src/context/RoomEventsContext/RoomEventsContext.tsx | head -10` + +- [ ] **Step 2: Update the provider to pass `decrypt` to `useRoomSubscriptions`** + +In `RoomEventsContext.tsx`: + +```tsx +import { useRoomKeys } from '@/context/RoomKeysContext' +// ... +const { decrypt } = useRoomKeys() +// ... +useRoomSubscriptions( + nats, + dispatch, + stateRef, + threadReplyHandlerRef, + threadMessageMutationHandlerRef, + decrypt, +) +``` + +- [ ] **Step 3: Extend `useRoomSubscriptions` signature** + +In `useRoomSubscriptions.js`, add the new parameter and a ref so callbacks see the latest: + +```js +export function useRoomSubscriptions( + nats, + dispatch, + stateRef, + threadReplyHandlerRef, + threadMessageMutationHandlerRef, + decrypt, +) { + // ... existing code ... + const decryptRef = useRef(decrypt) + decryptRef.current = decrypt + // ... use decryptRef.current(...) inside the subscription callbacks ... +} +``` + +- [ ] **Step 4: Decrypt new-message events before dispatching** + +Replace the `evt?.type === 'new_message'` branch in both `subscribeToUserRoomEvents` and the `openChannelSub` body. The dispatcher becomes async — extract a helper: + +```js +async function decryptAndDispatch(evt, dispatchFn) { + if (evt.encryptedMessage && !evt.message) { + const enc = evt.encryptedMessage + if (typeof enc.version === 'number' && enc.nonce && enc.ciphertext) { + const plaintext = await decryptRef.current({ + roomId: evt.roomId, + version: enc.version, + nonceB64: enc.nonce, + ciphertextB64: enc.ciphertext, + }) + if (plaintext != null) { + try { + const msg = JSON.parse(plaintext) + // Reuse the existing reducer path by populating evt.message. + evt = { ...evt, message: msg, encryptedMessage: undefined } + } catch (err) { + // eslint-disable-next-line no-console + console.warn('decrypted message is not valid JSON', err) + } + } + } + } + if (evt.messageEdited && evt.messageEdited.encryptedNewContent && !evt.messageEdited.newContent) { + const enc = evt.messageEdited.encryptedNewContent + if (typeof enc.version === 'number' && enc.nonce && enc.ciphertext) { + const plaintext = await decryptRef.current({ + roomId: evt.roomId, + version: enc.version, + nonceB64: enc.nonce, + ciphertextB64: enc.ciphertext, + }) + if (plaintext != null) { + evt = { + ...evt, + messageEdited: { ...evt.messageEdited, newContent: plaintext, encryptedNewContent: undefined }, + } + } + } + } + dispatchFn(evt) +} +``` + +Then in each subscription callback: + +```js +const dmSub = subscribeToUserRoomEvents(liveNats, (evt) => { + if (evt?.type === 'new_message') { + decryptAndDispatch(evt, (decoded) => { + safeDispatch({ type: 'MESSAGE_RECEIVED', event: decoded }) + fanThreadReply(decoded) + if (!decoded.message?.threadParentMessageId) { + scheduleMarkActiveRead(decoded.roomId) + } + }).catch((err) => console.warn('decryptAndDispatch DM failed', err)) + return + } + handleMutationEvent(evt) +}) + +const openChannelSub = (roomId) => { + if (channelSubs.current.has(roomId)) return + const sub = subscribeToRoomEvents(natsRef.current, { roomId }, (evt) => { + if (evt?.type === 'new_message') { + decryptAndDispatch(evt, (decoded) => { + const hasMention = (decoded.mentions ?? []).some((p) => p.account === user.account) + const normalized = { ...decoded, hasMention } + safeDispatch({ type: 'MESSAGE_RECEIVED', event: normalized }) + fanThreadReply(normalized) + if (!decoded.message?.threadParentMessageId) { + scheduleMarkActiveRead(decoded.roomId ?? roomId) + } + }).catch((err) => console.warn('decryptAndDispatch channel failed', err)) + return + } + if (evt?.type === 'message_edited') { + decryptAndDispatch(evt, (decoded) => { + handleMutationEvent(decoded) + }).catch((err) => console.warn('decryptAndDispatch edit failed', err)) + return + } + handleMutationEvent(evt) + }) + channelSubs.current.set(roomId, sub) +} +``` + +- [ ] **Step 5: Run the existing tests** + +Run: `cd chat-frontend && npm test` +Expected: All existing tests pass. Some may need a small tweak if they pass `useRoomSubscriptions` without the new `decrypt` arg — provide a no-op default for the param to keep backward compatibility: + +```js +export function useRoomSubscriptions( + nats, dispatch, stateRef, threadReplyHandlerRef, threadMessageMutationHandlerRef, + decrypt = async () => null, +) { +``` + +- [ ] **Step 6: Typecheck** + +Run: `cd chat-frontend && npm run typecheck` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add chat-frontend/src/context/RoomEventsContext/ +git commit -m "feat(chat-frontend): decrypt new-message + edit events before dispatch" +``` + +--- + +### Task 26: Reducer test — decoded event flows through normally + +**Files:** +- Modify: `chat-frontend/src/context/RoomEventsContext/reducer.test.js` + +The existing test at line 190 covers the "no plaintext, only encryptedMessage" fallback. Add a new test covering the post-decrypt happy path: when `evt.message` is populated (by the dispatcher) and `evt.encryptedMessage` is absent, the reducer treats it as a normal new-message event. + +- [ ] **Step 1: Append the test** + +```js +it('treats a decrypted MESSAGE_RECEIVED as a normal new-message event', () => { + const initial = { + /* paste the standard initial-state fixture used by other tests in this file */ + } + const reducer = /* import the existing reducer used by the file */ + const action = { + type: 'MESSAGE_RECEIVED', + event: newMessageEvent({ + message: { id: 'm1', roomId: 'r1', content: 'decrypted body', createdAt: '2026-05-20T00:00:00Z' }, + }), + } + const next = reducer(initial, action) + const inserted = next.roomState.r1.messages.find((m) => m.id === 'm1') + expect(inserted).toBeDefined() + expect(inserted.content).toBe('decrypted body') + expect(inserted.encrypted).toBeFalsy() +}) +``` + +Adapt `newMessageEvent` and `initial`/`reducer` imports to match the file's existing patterns — read the top of the file first to see the existing helpers. + +- [ ] **Step 2: Run the test** + +Run: `cd chat-frontend && npx vitest run src/context/RoomEventsContext/reducer.test.js` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add chat-frontend/src/context/RoomEventsContext/reducer.test.js +git commit -m "test(chat-frontend): reducer handles decrypted event identically to plaintext" +``` + +--- + +## Phase 7 — Docs and cleanup + +### Task 27: Update `docs/client-api.md` + +**Files:** +- Modify: `docs/client-api.md` + +- [ ] **Step 1: Locate the encryption section** + +Run: `grep -n "encryption\|encryptedMessage\|EphemeralPublicKey\|roomcrypto" docs/client-api.md` + +- [ ] **Step 2: Rewrite the encryption section** + +Update the wire-format description to match the new shape `{version, nonce, ciphertext}`. Remove every reference to `ephemeralPublicKey`. Add a new subsection for the bootstrap RPC `chat.user.{account}.request.rooms.keys`. Update the §4.1 RoomEvent example so the embedded `encryptedMessage` matches the new format. + +Concrete content to add (paste verbatim into the appropriate place): + +````markdown +### Room message encryption + +`encryptedMessage` on a `RoomEvent` is JSON with the following shape: + +```json +{ + "version": 7, + "nonce": "base64-12-bytes", + "ciphertext": "base64-content-plus-16-byte-tag" +} +``` + +Decoding procedure: + +1. Look up the room private key for `version` from the client-side key + store. If absent, render the message as `[encrypted message]` until + the key arrives via `chat.user.{account}.event.room.key` or a fresh + bootstrap call. +2. Derive an AES-256 key: + `aesKey = HKDF-SHA256(roomPrivateKey, salt=empty, info="room-message-encryption-v2", length=32)`. +3. Decrypt: `AES-GCM-Decrypt(aesKey, nonce, ciphertext, aad=empty)`. The + ciphertext already includes the 16-byte GCM tag at the end (Go's + `cipher.AEAD.Seal` format). +4. The plaintext is a UTF-8-encoded JSON `ClientMessage` for + `encryptedMessage`, or a UTF-8 string for + `messageEdited.encryptedNewContent`. + +### `chat.user.{account}.request.rooms.keys` + +Request payload: empty object `{}`. + +Response: `{"keys": [{"roomId": "...", "version": 0, "privateKey": "base64-32-bytes"}, ...]}`. + +The server returns one entry per room the caller is currently subscribed +to and for which a key exists in Valkey. Clients call this RPC on +(re)connect to seed their key cache before subscribing to live updates +on `chat.user.{account}.event.room.key`. +```` + +- [ ] **Step 3: Commit** + +```bash +git add docs/client-api.md +git commit -m "docs(client-api): document HKDF-only encryption and keys-bootstrap RPC" +``` + +--- + +### Task 28: Final verification + +- [ ] **Step 1: Server lint + tests** + +Run: +```bash +make lint +make test +make sast +``` +Expected: All pass. Fix any new issues. + +- [ ] **Step 2: chat-frontend typecheck + tests + build** + +Run: +```bash +cd chat-frontend +npm run typecheck +npm test +npm run build +``` +Expected: All pass. + +- [ ] **Step 3: Integration tests (Docker required)** + +Run: +```bash +make test-integration SERVICE=roomcrypto +make test-integration SERVICE=room-service +make test-integration SERVICE=broadcast-worker +``` +Expected: All pass. + +- [ ] **Step 4: Final benchmark capture** + +Run: `go test -bench=. -benchmem -benchtime=2s -run=^$ ./pkg/roomcrypto/` + +Confirm `BenchmarkEncoder_Encode` is in the hundreds of nanoseconds range (vs the legacy `BenchmarkEncode` was ~87 µs). If the number is dramatically different from expectations, investigate before merging. + +- [ ] **Step 5: Final commit (optional — only if there are stragglers)** + +If steps 1–4 surface any new fixes, commit them with a concise message; otherwise this task ends here. + +--- + +## Plan summary + +- **Tasks 1–9:** Server `pkg/roomcrypto` + `broadcast-worker` migration. New `Encoder` introduced TDD, legacy `Encode` removed once callers migrate, integration test updated to new TS decoder. +- **Tasks 10–13:** New `room-service` RPC `chat.user.{account}.request.rooms.keys` plus model types and an optional integration test. +- **Tasks 14–17:** chat-frontend `lib/roomcrypto` module via Web Crypto API, locked against the Go encoder by a committed cross-language fixture. +- **Tasks 18–21:** chat-frontend API ops — subject builders, types, `subscribeToRoomKeyEvents`, `fetchRoomKeysBootstrap`. +- **Tasks 22–24:** chat-frontend `RoomKeysContext` — reducer, provider with bootstrap-on-mount + live subscription, ref-backed `CryptoKey` cache, mounted in `App.jsx`. +- **Tasks 25–26:** chat-frontend `RoomEventsContext` integration — decrypt-then-dispatch in `useRoomSubscriptions`, new reducer test for the decoded happy path. +- **Tasks 27–28:** `docs/client-api.md` update and final verification gate. diff --git a/docs/superpowers/specs/2026-05-20-ecdh-performance-analysis-design.md b/docs/superpowers/specs/2026-05-20-ecdh-performance-analysis-design.md new file mode 100644 index 000000000..c2e89b167 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-ecdh-performance-analysis-design.md @@ -0,0 +1,772 @@ +# ECDH Ephemeral Key Performance — Analysis and Design Spec + +**Date:** 2026-05-20 +**Status:** Draft — awaiting review +**Scope:** Replace the per-message ECIES-style encryption scheme in +`pkg/roomcrypto` with a versioned symmetric AES key derived once per room key +version via HKDF, eliminating ECDH and ephemeral-keygen from the hot path. +Includes both the `broadcast-worker` change and the first chat-frontend +decoder implementation. Drops the legacy scheme entirely; no dual-scheme +migration window. + +--- + +## Overview + +`pkg/roomcrypto.Encode` is called on every broadcast event for an encrypted +channel room (see `broadcast-worker/handler.go:210, 254`). It generates a +fresh P-256 ephemeral key pair, performs ECDH against the room public key, +HKDFs the shared secret into a 32-byte AES key, and AEAD-seals the message +under that key. The ephemeral public key is sent on the wire so clients +could repeat the ECDH on their side. + +This spec replaces the scheme with a versioned symmetric AES key derived +directly from the room private key: +`aesKey_v = HKDF-SHA256(roomPriv_v, salt=nil, info="room-message-encryption-v2")`. +The wire format drops `ephemeralPublicKey` outright. Both server and +chat-frontend are updated in this branch. The Swift client lives outside this +repo and will need its own update; coordination of that work is out of scope +here. + +--- + +## Today's implementation + +Per call to `roomcrypto.Encode(content, roomPublicKey, version)`: + +1. Parse the 65-byte uncompressed P-256 public key. +2. Generate a fresh ephemeral P-256 key pair (`ecdh.P256().GenerateKey`). +3. ECDH between the ephemeral private key and the room public key. +4. HKDF-SHA256 the shared secret with `info="room-message-encryption"` into a + 32-byte AES key. +5. AES-256-GCM seal with a random 96-bit nonce. +6. Return `{version, ephemeralPublicKey, nonce, ciphertext}`. + +The room key pair lives in Valkey (`pkg/roomkeystore`). The server holds +both halves; only the public half is used at encrypt time. Clients receive +the private half via `room-key-sender` on the subject +`chat.user.{account}.event.room.key`. + +Today **no client in this monorepo actually decrypts.** chat-frontend +renders an `[encrypted message]` placeholder +(`chat-frontend/src/context/RoomEventsContext/reducer.js:300-318`). The +Swift client (out-of-repo) does decode but is treated as a separate-track +update for this change. + +--- + +## Performance measurement + +Benchmark file: `pkg/roomcrypto/bench_test.go`. Run with +`go test -bench=. -benchmem -benchtime=2s -run=^$ ./pkg/roomcrypto/`. + +Hardware used for these numbers: Intel Xeon @ 2.80 GHz, Linux amd64, +Go 1.25.10 (the dev container; production silicon is comparable or faster). + +| Operation | ns/op | allocs/op | Share of `Encode` | +| ---------------------------------- | ------- | --------- | ----------------- | +| Full `Encode` (current) | 87,185 | 39 | 100% | +| P-256 ECDH (scalar mult) | 64,823 | 2 | 74% | +| P-256 ephemeral keygen | 16,905 | 7 | 19% | +| Parse uncompressed P-256 pub | 380 | 5 | 0.4% | +| **Symmetric-only path (proposed)** | **200** | **1** | **0.2%** | +| X25519 keygen (reference) | 54,115 | 5 | — | +| X25519 ECDH (reference) | 53,847 | 1 | — | + +### Implications at sustained throughput + +| Rate | Current `Encode` CPU | Proposed CPU | CPU saved | +| ---------- | -------------------- | ------------ | ---------------- | +| 1K msg/s | 87 ms/s (~8.7% core) | 0.2 ms/s | ~99.8% | +| 10K msg/s | 870 ms/s (~87% core) | 2 ms/s | ~99.8% | +| 100K msg/s | 8.7 s/s (~8.7 cores) | 20 ms/s | effectively all | + +Allocations drop from 39/op to 1/op; at 10K msg/s that's ~390K allocs/s of +GC pressure removed from the broadcast hot path. + +### Note on X25519 + +X25519 is conventionally faster than P-256, but Go's standard library ships +amd64 assembly for P-256 (`p256_asm_amd64.s` using ADX/BMI2) and not for +X25519. On amd64 our benchmarks show P-256 ECDH (~65 µs) is **faster** than +X25519 ECDH (~54 µs in keygen, ~54 µs in ECDH). Curve switching is rejected +on benchmark evidence in this deployment. (On arm64 the picture would +invert; revisit if the deployment substrate changes.) + +--- + +## Threat model and forward-secrecy analysis + +This system is **not** end-to-end encrypted. The server holds the room +private key in Valkey (`pkg/roomkeystore/roomkeystore.go:18-19`). The +encryption scheme protects ciphertexts in two places only: + +1. **In-flight broadcast events** carried over NATS (including federation via + OUTBOX/INBOX). +2. **Nothing else.** `message-worker` writes messages to Cassandra in + plaintext; `history-service` and `search-service` do not touch + `roomcrypto`. Encrypted ciphertexts are transient — once a broadcast event + is consumed and acknowledged it is gone from the system. + +The realistic threats this encryption blunts: + +- A NATS observer who can see traffic but does not have Valkey access cannot + read message bodies. +- A federation peer that mishandles inbound traffic cannot read message + bodies without the sending site's Valkey contents. + +The realistic threats this encryption does **not** blunt: + +- Anyone with Valkey access reads every room private key and can decrypt + every ciphertext, past and present, because the ephemeral public key + travels alongside on the wire. + +### What about forward secrecy? + +Forward secrecy means *past sessions remain confidential after long-term +key compromise*. Here, the long-term key is the room private key in Valkey. +Per-message ephemeral keys give **no** forward secrecy against Valkey +compromise: an attacker with `room_priv` runs ECDH against the +`ephemeralPublicKey` already on the wire, regenerates the per-message AES +key, and decrypts. + +The only FS this system actually has is at the **key-rotation boundary**: +once a `VersionedKeyPair` ages out of Valkey's previous-key slot (per +`VALKEY_KEY_GRACE_PERIOD`), ciphertexts encrypted under the retired version +are no longer decryptable from current Valkey state. The new scheme +preserves this property unchanged — per-version forward secrecy is exactly +what the versioned symmetric key gives you. + +### What the ephemeral key actually buys today + +1. **Wire-level non-determinism.** Also provided by the random GCM nonce. +2. **An AES-GCM nonce-reuse safety net.** Since the AES key is per-message, + a nonce collision across messages cannot break confidentiality. This is + real defense-in-depth, but indirect: it hedges against a separate bug in + the OS CSPRNG, which would catastrophically break the whole system. +3. **A compliance/audit talking point** that each message has its own key. + +Items 1 and 3 are not security properties. Item 2 is the only real value +and is discussed under Risks below. + +--- + +## Alternatives considered + +### A. Status quo + +Keep paying ~87 µs per message. Easiest to ship (zero work). Rejected +because the broadcast hot path keeps burning CPU on cryptographic +operations that provide no security benefit beyond what the proposed +scheme provides for free. + +### B. Cache ephemeral key per (room, key-version), keep wire format + +Server-only change: cache the ephemeral keypair the first time a key +version is used, then reuse it for every subsequent message in that +version. Wire format and client code unchanged. Captures ~99% of the perf +win with zero client coordination. Rejected because the user has accepted +the client coordination cost in exchange for the cleaner end state, and +because chat-frontend has no existing decoder to break — there's nothing +to coordinate with on this side of the wire. + +### C. Switch curve to X25519 + +Conventional wisdom says X25519 beats P-256. False on amd64 Go — see the +X25519 note above. Rejected on benchmark evidence. + +### D. Precompute a pool of ephemeral keys + +Amortizes keygen but does nothing about the ECDH cost, which is 74% of +`Encode`. Doesn't move the needle enough to justify the engineering. +Rejected. + +### E. **HKDF-only versioned symmetric key** (recommended) + +Drop ECDH entirely. The room private key (32 bytes of high-entropy scalar) +is already perfectly good IKM. Derive the per-version AES key as +`HKDF-SHA256(roomPriv_v, salt=nil, info="room-message-encryption-v2")`, +cache it on both sides keyed on `(roomId, version)`, and use it directly. +Wire format becomes `{version, nonce, ciphertext}` — no +`ephemeralPublicKey`. Per-version FS preserved; per-message FS (which we +never had) unchanged. Hot path becomes a nonce read + GCM seal: ~200 ns per +message. + +> **Implemented note (2026-05-21):** The HKDF step was subsequently removed. +> The 32-byte room secret is used directly as the AES-256-GCM key +> (`aesKey = roomPrivateKey`) — no key derivation. Since the secret is +> generated by `crypto/rand` it is already uniform random material; HKDF +> provided no confidentiality benefit. The `golang.org/x/crypto/hkdf` import +> is gone; the info string `"room-message-encryption-v2"` is retired. + +--- + +## Recommendation + +Adopt Alternative E. Replace the existing scheme entirely (no dual-scheme +migration window). Server and chat-frontend ship together in this branch. + +--- + +## New wire format + +```go +// pkg/roomcrypto/roomcrypto.go +type EncryptedMessage struct { + // Version is the room key version; matches roomkeystore VersionedKeyPair.Version. + Version int `json:"version"` + + // Nonce is the 12-byte AES-GCM nonce. + Nonce []byte `json:"nonce"` + + // Ciphertext is content || 16-byte GCM tag. + Ciphertext []byte `json:"ciphertext"` +} +``` + +This is a **breaking** change to the wire format. The old `EphemeralPublicKey` +field is removed entirely. Clients on the old scheme (i.e., the Swift +client, until separately updated) will fail to decode messages from a +server running this change and will display whatever fallback their +decoder surfaces. chat-frontend's existing "[encrypted message]" +placeholder code is replaced with a real decoder in this PR, so it sees no +regression. + +--- + +## Algorithm + +### Server `(*Encoder).Encode(roomID, content, roomPrivateKey, version)` + +1. Look up (or compute and cache) `aesKey_v` for `(roomID, version)`. On + cache miss: + `aesKey_v = HKDF-SHA256(roomPrivateKey, salt=nil, info=[]byte("room-message-encryption-v2"))`, + read 32 bytes; construct an `aes.NewCipher` + `cipher.NewGCM`; store the + resulting `cipher.AEAD` in the cache. +2. Generate a fresh 12-byte nonce from `crypto/rand`. +3. `gcm.Seal(nil, nonce, []byte(content), nil)`. +4. Return `EncryptedMessage{Version: version, Nonce: nonce, Ciphertext: ciphertext}`. + +Two notable API changes: + +- **`Encode` now takes the room private key, not the public key.** The + server already has `key.KeyPair.PrivateKey` in + `broadcast-worker/handler.go`. Lines 210 and 254 swap + `key.KeyPair.PublicKey` for `key.KeyPair.PrivateKey`. +- **`Encode` is now a method on `*Encoder`,** not a free function. The + encoder owns the per-version AES-GCM cipher cache, eviction policy, and + the `crypto/rand` reader. + +```go +// pkg/roomcrypto/roomcrypto.go +type cacheKey struct { + roomID string + version int +} + +type Encoder struct { + mu sync.RWMutex + cache map[cacheKey]cipher.AEAD + rand io.Reader // crypto/rand.Reader; overridable for tests + max int // MaxCacheEntries +} + +func NewEncoder(opts ...EncoderOption) *Encoder + +func (e *Encoder) Encode(roomID, content string, roomPrivateKey []byte, version int) (*EncryptedMessage, error) +``` + +The free function `roomcrypto.Encode` is **removed** in this PR. All +callers (`broadcast-worker/handler.go:210, 254`) move to the encoder. + +### Client decrypt (chat-frontend) + +```ts +// chat-frontend/src/lib/roomcrypto/roomcrypto.ts (new module) + +export async function decryptRoomMessage( + ciphertext: Uint8Array, + nonce: Uint8Array, + aesKey: CryptoKey, +): Promise + +export async function deriveAesKey( + roomPrivateKey: Uint8Array, // 32-byte raw key +): Promise +``` + +Internals: + +1. `deriveAesKey` uses `crypto.subtle.importKey('raw', roomPrivateKey, 'HKDF', ...)` + then `crypto.subtle.deriveKey({name:'HKDF', hash:'SHA-256', salt:new Uint8Array(0), info:utf8('room-message-encryption-v2')}, ikm, {name:'AES-GCM', length:256}, false, ['decrypt'])`. +2. `decryptRoomMessage` calls `crypto.subtle.decrypt({name:'AES-GCM', iv:nonce, tagLength:128}, aesKey, ciphertext)`, then `TextDecoder('utf-8').decode(...)`. + +Caller (the room-keys context) wraps `deriveAesKey` so the resulting +`CryptoKey` is cached per `(roomId, version)`. + +--- + +## Server changes + +### `pkg/roomcrypto/roomcrypto.go` + +- `EncryptedMessage` struct: drop `EphemeralPublicKey`. Keep + `Version`, `Nonce`, `Ciphertext`. +- Remove the free `Encode` function. +- Add `Encoder` struct, `NewEncoder()`, `EncoderOption`, and + `(*Encoder).Encode(roomID, content, roomPrivateKey, version)`. +- Per-version cache: `map[cacheKey]cipher.AEAD`, protected by + `sync.RWMutex`, bounded by `MaxCacheEntries` (default 4096; env override + `ROOM_CRYPTO_CACHE_SIZE`) with simple lowest-version eviction described + under Implementation Details. + +### `pkg/roomcrypto/roomcrypto_test.go` + +- Drop all tests that exercise the legacy `Encode` / `EphemeralPublicKey` + path. The legacy code is gone; testing it is dead weight. +- New table-driven `TestEncoder_Encode`: happy path, empty content, too-short + private key (31 bytes), too-long private key (33 bytes), version stamped + on output, no `EphemeralPublicKey` field present in JSON. +- New `TestEncoder_RoundTrip`: encode, then decode inline using + HKDF-SHA256(privKey, info="room-message-encryption-v2") + AES-GCM, content + matches. +- New `TestEncoder_CacheHit`: two encodes for same (roomID, version) derive + the AES key only once (instrument via a test hook on the encoder). +- New `TestEncoder_CacheEviction`: fill to `MaxCacheEntries`, add one more, + assert lowest-version entry evicted. +- New `TestEncoder_NonDeterminism`: two encodes for same inputs produce + different nonces and different ciphertexts (under the same cached AES key, + this is solely a property of the random nonce). +- New `TestEncoder_RandReaderError`: inject a failing reader; nonce + generation surfaces a wrapped error. + +### `pkg/roomcrypto/bench_test.go` + +- Already committed. Update to benchmark `(*Encoder).Encode` as the + primary case, leave the curve-comparison benchmarks for posterity. + +### `pkg/roomcrypto/integration_test.go` + +- Existing test uses a Node container to validate a TypeScript decrypt + script against `pkg/roomcrypto.Encode`. Update the TS decrypt script to + the new HKDF-only algorithm; update `decryptPayload` struct accordingly + (drop the publicKey field; keep privateKey). + +### `broadcast-worker/handler.go` + +- Hold `*roomcrypto.Encoder` on the handler struct, constructed in + `main.go`. +- Lines 210 and 254: replace + `roomcrypto.Encode(content, key.KeyPair.PublicKey, key.Version)` with + `h.encoder.Encode(roomID, content, key.KeyPair.PrivateKey, key.Version)`. + Pass `meta.ID` / `edited.RoomID` (or `msg.RoomID` — whichever is in + scope) as the `roomID` argument. + +### `broadcast-worker/main.go` + +- Parse `ROOM_CRYPTO_CACHE_SIZE` (envDefault `4096`). +- Construct the encoder via `roomcrypto.NewEncoder(roomcrypto.WithMaxCacheEntries(cfg.RoomCryptoCacheSize))`. +- Pass to the handler constructor. + +### `broadcast-worker/handler_test.go` + +- Mock expectations stay the same (`pkg/roomcrypto` has no mocks; a real + encoder is injected with no external dependencies). +- New case: published event's `encryptedMessage` JSON contains + `version`, `nonce`, `ciphertext` but **no** `ephemeralPublicKey` field. + +### `pkg/model/event.go` + +- No struct changes. `RoomEvent.EncryptedMessage` remains + `json.RawMessage`; the inner shape change lives in `pkg/roomcrypto`. + +### `docs/client-api.md` + +- Update §4.1 RoomEvent description and the room-encryption section to + describe the new wire layout (no ephemeral public key) and the new + derivation algorithm. Per project rule, this doc update is in the same + PR as the broadcast-worker change. + +### Mock regeneration + +- `make generate SERVICE=broadcast-worker` after the handler signature + changes. + +--- + +## chat-frontend changes + +The frontend changes implement room-key handling and message decryption +end-to-end. This is greenfield work — there is no existing decoder to +extend or be compatible with. + +### New module: `src/lib/roomcrypto/roomcrypto.ts` + +Pure utility using Web Crypto API. Per `chat-frontend/CLAUDE.md`, `lib/` +is the right home: no React, no NATS, no async I/O beyond `crypto.subtle`. + +```ts +// Derive an AES-256-GCM CryptoKey from a 32-byte room private key. +export async function deriveAesKey(roomPrivateKey: Uint8Array): Promise + +// Decrypt {nonce, ciphertext} produced by the server encoder. +export async function decryptRoomMessage( + ciphertext: Uint8Array, + nonce: Uint8Array, + aesKey: CryptoKey, +): Promise + +// Decode helpers: base64 → Uint8Array. Re-exported for tests + callers. +export function b64decode(s: string): Uint8Array +``` + +Test file `roomcrypto.test.ts` covers: + +- Round-trip against a fixed Go-produced ciphertext (committed as test + fixture under `chat-frontend/test/fixtures/`). +- HKDF parameters match server (info string, salt=empty, 32-byte output). +- Reject ciphertext with wrong tag (GCM verification failure surfaces a + clear error). +- Reject ciphertext with wrong nonce length. + +### New API op: `src/api/subscribeToRoomKeyEvents/index.ts` + +```ts +export function subscribeToRoomKeyEvents( + nats: Nats, + onEvent: (evt: RoomKeyEvent) => void, +): Subscription +``` + +Subject: `chat.user.${account}.event.room.key` (build via +`api/_transport/subjects.ts` — add `userRoomKey(account)`). Wire shape +mirrors `pkg/model.RoomKeyEvent`: `{ roomId, version, privateKey, timestamp }` +where `privateKey` is base64. + +Re-exported from `src/api/index.ts`. + +### Extend `RoomsInfoBatch` bootstrap (existing RPC) + +`room-service/handler.go:874` already returns `privateKey` + `keyVersion` +per room from the batch RPC. chat-frontend does not call this RPC today. +Two options: + +- **Option F1 (recommended):** add a new lightweight RPC + `chat.user.{account}.request.rooms.keys` that, given the caller's + subscribed room IDs (derived server-side from the user's subscriptions), + returns `[{roomId, version, privateKey}]`. Cleaner API surface; isolates + the key-bootstrap concern. +- **Option F2:** call the existing `RoomsInfoBatch` RPC for the keys. Less + new server-side surface, but couples key bootstrap to room-info fetch + (which today is on-demand, not on-connect). + +For this spec, go with **F1**. New handler in `room-service`: + +- Subject: `chat.user.{account}.request.rooms.keys`. +- Request: empty (or `{roomIds: []}` — but the server has the + authoritative subscription list, so it can derive without trusting + client input). +- Response: `[{roomId, version, privateKey}]` — only rooms the caller is + subscribed to AND that have a key in Valkey. + +Add tests in `room-service/handler_test.go` and update `docs/client-api.md`. + +### New context: `src/context/RoomKeysContext/` + +Folder layout per `chat-frontend/CLAUDE.md`: + +``` +context/RoomKeysContext/ +├── RoomKeysContext.tsx ← provider + useRoomKeys() hook +├── reducer.ts ← state machine +├── useKeyBootstrap.ts ← initial fetch hook +├── index.tsx ← re-export +└── *.test.{ts,tsx} +``` + +State shape: + +```ts +type StoredKey = { + privateKey: Uint8Array // raw 32-byte scalar (base64-decoded) + aesKey?: CryptoKey // lazily derived, cached +} + +type RoomKeysState = { + // outer key: roomId; inner key: version + byRoom: Record> + bootstrapped: boolean +} +``` + +Actions: + +- `BOOTSTRAP_LOADED`: bulk insert from the new keys RPC. +- `KEY_RECEIVED`: insert one `{roomId, version, privateKey}` from the + subscription. Idempotent — replacing an existing entry is fine (the same + privateKey should arrive). +- `CLEAR_KEYS`: on logout/reconnect, clear all state. + +Provider responsibilities: + +1. On mount (post-login), call the new keys-RPC; dispatch + `BOOTSTRAP_LOADED`. +2. Subscribe to `subscribeToRoomKeyEvents`; dispatch `KEY_RECEIVED` on each + event. +3. Unsubscribe + clear on unmount/reconnect. + +Exported hook `useDecryptMessage(roomId, version, ciphertextB64, nonceB64): Promise`: + +- Look up `byRoom[roomId]?.[version]`. If absent, return `null` (caller + surfaces placeholder). +- If `aesKey` not cached, derive it via `deriveAesKey(privateKey)` and + store back into state via a dispatch (or write directly into the + ref-backed cache; see Implementation Details). +- Call `decryptRoomMessage(ciphertext, nonce, aesKey)`; return the + plaintext on success or `null` on failure. + +### Reducer integration: `src/context/RoomEventsContext/` + +Today's `MESSAGE_RECEIVED` handler at `reducer.js:296-318` does the +"synthesize a placeholder" fallback when `evt.encryptedMessage` is +present and `evt.message` is not. Replace this with a decrypt attempt +**before** the placeholder fallback: + +The trick: the reducer is pure synchronous JS. Decryption is async. +Resolution: do the decrypt in the **dispatcher** (the +`subscribeToRoomEvents` callback in `useRoomSubscriptions.js:221`), not in +the reducer. When `evt.encryptedMessage` is present: + +1. Look up the key from the room-keys context. +2. Attempt to decrypt via `useDecryptMessage` (or the underlying async + function exposed by the context). +3. If decrypt succeeds: parse the JSON `ClientMessage`, replace + `evt.message` with the decoded message, drop `evt.encryptedMessage`, + dispatch `MESSAGE_RECEIVED` as usual. +4. If decrypt fails (no key, wrong version, GCM tag mismatch): dispatch a + variant that keeps the placeholder path (today's behavior). + +The reducer itself only sees fully-decoded events. The placeholder +fallback at lines 308-318 stays as the last-resort branch for events that +arrived before the key did. + +Edits (`MessageEditedPayload.encryptedNewContent` at +`pkg/model/event.go:152`) take the same treatment: decrypt in the +dispatcher, hand the plaintext `NewContent` to the reducer. + +### Tests + +Following `chat-frontend/CLAUDE.md` testing conventions: + +- `lib/roomcrypto/roomcrypto.test.ts`: round-trip with a Go-produced + fixture (see Implementation Details for how the fixture is generated). +- `api/subscribeToRoomKeyEvents/index.test.ts`: argument-to-payload + mapping; subject correctness; callback receives parsed event. +- `context/RoomKeysContext/reducer.test.ts`: pure JS tests on all three + actions; idempotency of `KEY_RECEIVED`. +- `context/RoomKeysContext/RoomKeysContext.test.tsx`: provider mounts, + calls bootstrap RPC, subscribes, dispatches; unmount unsubscribes. +- `context/RoomEventsContext/reducer.test.js`: existing + "placeholder when only encryptedMessage" case stays — covers the + no-key-yet branch. **New** case: decrypted event arrives with + populated `message`, reducer treats it as a normal new-message event. +- `context/RoomEventsContext/useRoomSubscriptions.test.js` (or a new + test file): dispatcher path decrypts successfully, then dispatches the + decoded event. + +### Smoke + +Add a new `scripts/encryption.smoke.mjs` (or extend the existing +`smoke-test.mjs`) that runs end-to-end against the live stack: send an +encrypted message, then assert chat-frontend's decoder produces the +expected plaintext. + +### What this does NOT change in chat-frontend + +- `RoomEvent` wire type stays the same (`encryptedMessage` is still + `unknown`/`json.RawMessage`-equivalent at the TS layer). +- `subscribeToRoomEvents` stays the same. +- The placeholder rendering code path is preserved as a fallback. +- No UI changes — decrypted messages flow through the same render path + plaintext messages do today. + +--- + +## Migration plan + +One deploy. Server and chat-frontend ship in the same PR. Encrypted +broadcast events are transient; there are no historical scheme-0 +ciphertexts to support. The Swift client (out-of-repo) will see decode +failures from the moment the server flips — coordination of that update +is outside this branch's scope and tracked separately. + +Deployment order if any: chat-frontend first, then server. Either order +is safe (chat-frontend's placeholder fallback handles any scheme it can't +decode), but frontend-first means users see the new decoded messages the +moment the server flips, with no in-flight gap. + +Rollback: revert the PR. Encrypted ciphertexts are transient; nothing +persistent depends on the new format. + +--- + +## Implementation details + +### Cache key (server) + +The per-version AES key depends on `(roomID, version)` because different +rooms have different private keys. Cache key: + +```go +type cacheKey struct { + roomID string + version int +} +``` + +### Cache size and eviction (server) + +- Default `MaxCacheEntries = 4096` (override via env + `ROOM_CRYPTO_CACHE_SIZE`, parsed in `broadcast-worker/main.go`). +- Eviction: on insert over the limit, drop the entry with the lowest + `version` value across all rooms. Deliberately simple — hot rooms keep + their entries, rare rooms with old versions get dropped first. Linear + scan per eviction is acceptable at n=4096. +- Cache entries are `cipher.AEAD` values, not raw bytes — derived once + via `aes.NewCipher` + `cipher.NewGCM` and reused for every `Seal`. + +### Key-version rotation correctness + +On rotation, `broadcast-worker` reads the new `VersionedKeyPair` from +Valkey on its next message. `(*Encoder).Encode` misses on the new +version, derives, caches. Old entries age out under the size bound. The +old AES key is never asked for again because no encrypt path requests an +old version. chat-frontend keeps the old version in `RoomKeysState` +indefinitely so any in-flight messages encrypted under the old version +(during the rotation grace window) still decrypt; eviction policy on the +client side is "drop after `VALKEY_KEY_GRACE_PERIOD * 2`" or simpler +"keep up to N most recent versions per room"; see open question O3. + +### Nonce hygiene + +With one AES key per (room, version), nonce collision becomes a real +concern. `crypto/rand` 96-bit random nonces collide with probability +~2⁻³² after ~2³² messages **per key version**. Mitigations: + +1. **Document the rotation cadence as a security parameter.** Room-key + rotation policy must keep "messages per key version" comfortably below + 2³² ≈ 4.3B. At 10K msg/s on one room that's ~5 days between mandatory + rotations — trivially satisfied at realistic chat scale. +2. **Counter-based nonces if rotation cadence is ever in doubt.** Out of + scope here; flagged as future work. + +### Fixture generation for chat-frontend roundtrip tests + +A small Go program under `chat-frontend/scripts/gen-crypto-fixtures.go` +(invoked manually, output committed to +`chat-frontend/test/fixtures/encrypted-message.json`) encodes a known +plaintext with a known private key + version. The TS test reads the +fixture and verifies decryption produces the original plaintext. Cross- +language round-trip coverage in one direction is sufficient; the +integration test in `pkg/roomcrypto/integration_test.go` covers the +other direction (Go encode → Node decode via tsx). + +### Test reproducibility + +Benchmark numbers in this document were captured on the dev container's +Xeon @ 2.80 GHz. The relative ratios (Encode vs symmetric-only) should +hold across CPU generations. + +--- + +## Risks and open questions + +### R1. PRNG failure mode + +If `crypto/rand.Read` returns identical bytes on two calls under the +same AES key, GCM authenticity is broken and the auth key can be +recovered. The current scheme hides this behind per-message key +derivation; the new scheme is exposed to it directly. Mitigation: this +is a system-wide catastrophe under either scheme (every NKey signature, +JWT generation, etc. is equally affected), so localizing the concern to +this change is misleading. Accept the risk. + +### R2. Forgetting to rotate + +If a room never rotates, its AES key encrypts the room's entire history +under one key. Mitigation: document the rotation cadence as a security +parameter (see Nonce hygiene). If rotation discipline is a real concern, +follow-up work can add an automated rotation policy in `roomkeystore`. +Out of scope here. + +### R3. Swift client breaks at the moment of server flip + +The Swift client expects scheme-0 (with `ephemeralPublicKey`) on the +wire. The moment the server flips, Swift cannot decrypt. Mitigation: +out-of-scope for this branch; tracked separately. If Swift is on the +critical path, defer this change until the Swift update is ready. + +### O1. New RPC vs. extending an existing one for key bootstrap + +This spec proposes a new `chat.user.{account}.request.rooms.keys` RPC. +Alternative is to extend `RoomsInfoBatch` so chat-frontend calls it on +connect. Choosing the new RPC keeps responsibilities separate and avoids +a client behavior change ("now we call RoomsInfoBatch on connect, not +on-demand"). Confirm with reviewer before implementation. + +### O2. AES key derivation cached at decrypt site (client) + +`useDecryptMessage` returns a `Promise` and lazily caches +the derived `CryptoKey` per (roomId, version). Caching via dispatch +loops through the React reducer; caching via a ref is faster but lives +outside React state. Recommend: ref-backed map inside the context +provider, exposed via a stable callback. State carries only the raw +`privateKey`; the derived `CryptoKey` is a transient cache. + +### O3. Old-version retention policy (client) + +chat-frontend's `RoomKeysState.byRoom[roomId]` accumulates versions over +time. Two reasonable policies: + +- Keep all versions ever received in this session (memory leak in + long-lived tabs). +- Keep last N versions per room (default N=2 — current + previous). + +Recommend the latter, default N=2 (matches Valkey's previous-key grace +slot). Flag for reviewer. + +### O4. Edit-content key version + +`MessageEditedPayload.encryptedNewContent` is encrypted under whatever +the current key is at edit time. The decryptor needs the version. The +`EncryptedMessage` JSON inside `encryptedNewContent` carries `version` +already — no payload change needed. Just confirmed for the spec. + +--- + +## Future work (deferred) + +- **Counter-based nonces.** If sustained per-room throughput approaches + 2³² messages between rotations. +- **Automated key rotation policy.** Tie rotation to a max-messages or + max-age policy enforced in `roomkeystore`. +- **Swift client update.** Tracked separately. +- **Removing the chat-frontend `[encrypted message]` fallback once the + bootstrap path is rock-solid.** For now it stays as the last-resort + branch for events that arrive before their key does. +- **Persisting room keys to IndexedDB across reloads.** Today every + reload re-bootstraps via the keys RPC. Cheap at low room counts; revisit + if room counts per user grow large. + +--- + +## Out of scope + +- Swift client decoder implementation. +- Re-encrypting historical messages (none are persisted encrypted). +- Changes to `roomkeystore`, `room-key-sender`, or NATS subjects + beyond the new keys-bootstrap RPC. +- Changes to `message-worker`, `history-service`, `search-service`. +- Performance work outside `roomcrypto` and its caller in + `broadcast-worker`. +- Persisting room keys client-side beyond the lifetime of the tab. diff --git a/go.mod b/go.mod index ec58300b5..aa7cda428 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,6 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 go.uber.org/goleak v1.3.0 go.uber.org/mock v0.6.0 - golang.org/x/crypto v0.51.0 golang.org/x/sync v0.20.0 ) @@ -141,6 +140,7 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.25.0 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/net v0.54.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.44.0 // indirect diff --git a/pkg/model/event.go b/pkg/model/event.go index 1b1d6bf96..f19bc27ac 100644 --- a/pkg/model/event.go +++ b/pkg/model/event.go @@ -189,10 +189,8 @@ type RoomEvent struct { } type RoomKeyEvent struct { - RoomID string `json:"roomId"` - Version int `json:"version"` - // PublicKey is server-side only; omitted from the client wire payload (clients only need PrivateKey). - PublicKey []byte `json:"publicKey,omitempty"` + RoomID string `json:"roomId"` + Version int `json:"version"` PrivateKey []byte `json:"privateKey"` Timestamp int64 `json:"timestamp" bson:"timestamp"` } diff --git a/pkg/model/model_test.go b/pkg/model/model_test.go index a67c246f8..d06c1e29c 100644 --- a/pkg/model/model_test.go +++ b/pkg/model/model_test.go @@ -869,7 +869,6 @@ func TestRoomKeyEventJSON(t *testing.T) { src := model.RoomKeyEvent{ RoomID: "room-1", Version: 42, - PublicKey: []byte{0x04, 0x01, 0x02, 0x03}, PrivateKey: []byte{0x0a, 0x0b, 0x0c}, Timestamp: 1735689600000, } diff --git a/pkg/roomcrypto/bench_test.go b/pkg/roomcrypto/bench_test.go new file mode 100644 index 000000000..f405d874a --- /dev/null +++ b/pkg/roomcrypto/bench_test.go @@ -0,0 +1,31 @@ +package roomcrypto + +import ( + "crypto/rand" + "io" + "testing" +) + +// BenchmarkEncoder_Encode measures the hot path: encoder cache hit + +// nonce read + AES-GCM seal. This is what broadcast-worker pays per +// message at steady state. +func BenchmarkEncoder_Encode(b *testing.B) { + priv := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, priv); err != nil { + b.Fatal(err) + } + enc := NewEncoder() + + // Warm the cache so we measure steady-state, not one-time AES cipher construction. + if _, err := enc.Encode("room-1", "warm", priv, 1); err != nil { + b.Fatal(err) + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := enc.Encode("room-1", "hello, world — a typical short chat message", priv, 1); err != nil { + b.Fatal(err) + } + } +} diff --git a/pkg/roomcrypto/integration_test.go b/pkg/roomcrypto/integration_test.go index ee44e6e0a..3c2888ac0 100644 --- a/pkg/roomcrypto/integration_test.go +++ b/pkg/roomcrypto/integration_test.go @@ -5,7 +5,6 @@ package roomcrypto import ( "bytes" "context" - "crypto/ecdh" "crypto/rand" "encoding/base64" "encoding/json" @@ -27,8 +26,7 @@ import ( // decryptPayload is the JSON structure passed to the TypeScript decrypt script. type decryptPayload struct { - PrivateKey string `json:"privateKey"` // base64(privKey.Bytes()) — 32-byte P-256 scalar - PublicKey string `json:"publicKey"` // base64(pubKey.Bytes()) — 65-byte uncompressed point + PrivateKey string `json:"privateKey"` // base64 — 32 bytes of high-entropy IKM Message *EncryptedMessage `json:"message"` } @@ -101,18 +99,19 @@ func TestEncode_TypeScriptDecrypt(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - // Generate a fresh P-256 key pair for this subtest. - privKey, err := ecdh.P256().GenerateKey(rand.Reader) + // Generate a fresh 32-byte room private key (high-entropy IKM) for this subtest. + roomPriv := make([]byte, 32) + _, err := io.ReadFull(rand.Reader, roomPriv) require.NoError(t, err) - // Encrypt with Go. - msg, err := Encode(tc.content, privKey.PublicKey().Bytes(), 0) + // Encrypt with Go (room private key used directly as AES-256-GCM key). + enc := NewEncoder() + msg, err := enc.Encode("room-integration", tc.content, roomPriv, 1) require.NoError(t, err) // Build the JSON payload the TypeScript script expects. payload := decryptPayload{ - PrivateKey: base64.StdEncoding.EncodeToString(privKey.Bytes()), - PublicKey: base64.StdEncoding.EncodeToString(privKey.PublicKey().Bytes()), + PrivateKey: base64.StdEncoding.EncodeToString(roomPriv), Message: msg, } payloadJSON, err := json.Marshal(payload) @@ -125,8 +124,8 @@ func TestEncode_TypeScriptDecrypt(t *testing.T) { err = container.CopyFileToContainer(ctx, payloadFile, "/payload.json", 0o644) require.NoError(t, err, "copy payload.json into container") - // Run the TypeScript decrypt script. - exitCode, reader, err := container.Exec(ctx, []string{"tsx", "/decrypt.ts", "/payload.json"}) + // Run the TypeScript decrypt script, piping the payload via stdin. + exitCode, reader, err := container.Exec(ctx, []string{"sh", "-c", "cat /payload.json | tsx /decrypt.ts"}) require.NoError(t, err, "exec tsx decrypt") stdout, combined := splitOutput(reader) require.Equal(t, 0, exitCode, "decrypt script exited non-zero:\n%s", combined) diff --git a/pkg/roomcrypto/roomcrypto.go b/pkg/roomcrypto/roomcrypto.go index 836e682ac..5fe5d69ca 100644 --- a/pkg/roomcrypto/roomcrypto.go +++ b/pkg/roomcrypto/roomcrypto.go @@ -3,13 +3,10 @@ package roomcrypto import ( "crypto/aes" "crypto/cipher" - "crypto/ecdh" "crypto/rand" - "crypto/sha256" "fmt" "io" - - "golang.org/x/crypto/hkdf" + "sync" ) // EncryptedMessage holds the output of Encode. @@ -19,77 +16,139 @@ import ( // It is a serialisation-only type sent to clients over JSON; it is never written to MongoDB. type EncryptedMessage struct { // key version used to encrypt; matches roomkeystore VersionedKeyPair.Version - Version int `json:"version"` - EphemeralPublicKey []byte `json:"ephemeralPublicKey"` // 65 bytes, uncompressed P-256 point - Nonce []byte `json:"nonce"` // 12 bytes, AES-GCM nonce - Ciphertext []byte `json:"ciphertext"` // encrypted content + 16-byte AES-GCM tag + Version int `json:"version"` + Nonce []byte `json:"nonce"` // 12 bytes, AES-GCM nonce + Ciphertext []byte `json:"ciphertext"` // encrypted content + 16-byte AES-GCM tag } -// Encode encrypts content using the room's P-256 public key. -// roomPublicKey is the uncompressed point (65 bytes) as stored in MongoDB. -// version is stamped into the returned EncryptedMessage so receivers can -// pick the correct private key for decryption. -func Encode(content string, roomPublicKey []byte, version int) (*EncryptedMessage, error) { - return encode(content, roomPublicKey, version, rand.Reader) +// Encoder holds the per-(roomId, version) AES-GCM cipher cache. The room +// secret is used directly as an AES-256 key. Construct one per process and +// share it across goroutines. +type Encoder struct { + mu sync.RWMutex + cache map[encoderCacheKey]cipher.AEAD + rand io.Reader + max int } -// encode is the internal implementation that accepts an io.Reader for randomness, -// enabling error path testing without changing the public API. -func encode(content string, roomPublicKey []byte, version int, randReader io.Reader) (*EncryptedMessage, error) { - // Step 1: parse and validate the room public key - roomPubKey, err := ecdh.P256().NewPublicKey(roomPublicKey) - if err != nil { - return nil, fmt.Errorf("parsing room public key: %w", err) +type encoderCacheKey struct { + roomID string + version int +} + +// EncoderOption configures an Encoder at construction time. +type EncoderOption func(*Encoder) + +// WithMaxCacheEntries sets the upper bound on the per-(roomId, version) +// AES-GCM cache. When exceeded, the entry with the lowest version is +// evicted. Default 4096. +func WithMaxCacheEntries(n int) EncoderOption { + return func(e *Encoder) { e.max = n } +} + +// WithRand overrides the source of randomness used for nonce generation. +// Intended for testing only. +func WithRand(r io.Reader) EncoderOption { + return func(e *Encoder) { e.rand = r } +} + +// NewEncoder constructs an Encoder with default cache size 4096 and +// crypto/rand.Reader as the randomness source. +func NewEncoder(opts ...EncoderOption) *Encoder { + e := &Encoder{ + cache: make(map[encoderCacheKey]cipher.AEAD), + rand: rand.Reader, + max: 4096, } + for _, opt := range opts { + opt(e) + } + return e +} - // Step 2: generate a fresh ephemeral P-256 key pair for this message - ephemeralPrivKey, err := ecdh.P256().GenerateKey(randReader) +// Encode encrypts content using the AES-256-GCM cipher keyed directly from +// roomPrivateKey for the given (roomID, version). The cipher is cached on the +// Encoder; repeat calls for the same (roomID, version) reuse the cached entry. +func (e *Encoder) Encode(roomID, content string, roomPrivateKey []byte, version int) (*EncryptedMessage, error) { + gcm, err := e.aeadFor(roomID, roomPrivateKey, version) if err != nil { - return nil, fmt.Errorf("generating ephemeral key: %w", err) + return nil, fmt.Errorf("preparing cipher for room %s v%d: %w", roomID, version, err) } - // Step 3: ECDH — derive shared secret from ephemeral private key + room public key - sharedSecret, err := ephemeralPrivKey.ECDH(roomPubKey) - if err != nil { - // Unreachable: roomPubKey was validated by NewPublicKey above. - return nil, fmt.Errorf("computing ECDH shared secret: %w", err) + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(e.rand, nonce); err != nil { + return nil, fmt.Errorf("generating nonce: %w", err) } - // Step 4: HKDF-SHA256 — derive 32-byte AES key from shared secret - // info="room-message-encryption" provides domain separation - aesKey := make([]byte, 32) - hkdfReader := hkdf.New(sha256.New, sharedSecret, nil, []byte("room-message-encryption")) - if _, err := io.ReadFull(hkdfReader, aesKey); err != nil { - // Unreachable for SHA-256, but must be checked per project convention. - return nil, fmt.Errorf("deriving AES key: %w", err) + ciphertext := gcm.Seal(nil, nonce, []byte(content), nil) + return &EncryptedMessage{ + Version: version, + Nonce: nonce, + Ciphertext: ciphertext, + }, nil +} + +func (e *Encoder) aeadFor(roomID string, roomPrivateKey []byte, version int) (cipher.AEAD, error) { + if len(roomPrivateKey) != 32 { + return nil, fmt.Errorf("room private key must be 32 bytes, got %d", len(roomPrivateKey)) + } + + key := encoderCacheKey{roomID: roomID, version: version} + + e.mu.RLock() + gcm, ok := e.cache[key] + e.mu.RUnlock() + if ok { + return gcm, nil } - // Step 5: AES-256-GCM cipher setup - block, err := aes.NewCipher(aesKey) + block, err := aes.NewCipher(roomPrivateKey) if err != nil { - // Unreachable: aesKey is always 32 bytes from HKDF above. return nil, fmt.Errorf("creating AES cipher: %w", err) } - gcm, err := cipher.NewGCM(block) + newGCM, err := cipher.NewGCM(block) if err != nil { - // Unreachable: AES always produces a 128-bit block cipher. return nil, fmt.Errorf("creating GCM wrapper: %w", err) } - // Step 6: generate a random 12-byte nonce - nonce := make([]byte, gcm.NonceSize()) - if _, err := io.ReadFull(randReader, nonce); err != nil { - return nil, fmt.Errorf("generating nonce: %w", err) + e.mu.Lock() + defer e.mu.Unlock() + // Double-check under write lock — another goroutine may have populated. + if existing, ok := e.cache[key]; ok { + return existing, nil + } + if len(e.cache) >= e.max { + e.evictLowestVersionLocked() } + e.cache[key] = newGCM + return newGCM, nil +} - // Step 7: encrypt and authenticate; AAD is nil (see spec for trade-off rationale) - // Seal appends the 16-byte GCM authentication tag to the ciphertext. - ciphertext := gcm.Seal(nil, nonce, []byte(content), nil) +// evictLowestVersionLocked drops the entry with the lowest version +// across all rooms. This policy is deliberately simple (not LRU): hot +// rooms keep their entries via frequent insertions; rare rooms with +// old, unused versions are dropped first. Caller must hold e.mu for writing. +// +// Precondition: len(e.cache) > 0 (only called when cache is full). +func (e *Encoder) evictLowestVersionLocked() { + var ( + victim encoderCacheKey + haveFirst bool + ) + for k := range e.cache { + if !haveFirst || k.version < victim.version { + victim = k + haveFirst = true + } + } + if haveFirst { + delete(e.cache, victim) + } +} - return &EncryptedMessage{ - Version: version, - EphemeralPublicKey: ephemeralPrivKey.PublicKey().Bytes(), - Nonce: nonce, - Ciphertext: ciphertext, - }, nil +// cacheLen is exported only for tests in the same package. +func (e *Encoder) cacheLen() int { + e.mu.RLock() + defer e.mu.RUnlock() + return len(e.cache) } diff --git a/pkg/roomcrypto/roomcrypto_test.go b/pkg/roomcrypto/roomcrypto_test.go index 8aefe0946..68abf08b6 100644 --- a/pkg/roomcrypto/roomcrypto_test.go +++ b/pkg/roomcrypto/roomcrypto_test.go @@ -4,215 +4,160 @@ import ( "bytes" "crypto/aes" "crypto/cipher" - "crypto/ecdh" - "crypto/rand" - "crypto/sha256" "encoding/json" "errors" - "io" - "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "golang.org/x/crypto/hkdf" ) -func TestEncode(t *testing.T) { - // Generate a valid key pair once at the top of the test for use in the table. - // This is shared read-only setup for test cases — each subtest reads only the pubKey bytes, - // it does not mutate state. - privKey, err := ecdh.P256().GenerateKey(rand.Reader) - require.NoError(t, err) - validPubKey := privKey.PublicKey().Bytes() - - tests := []struct { - name string - content string - pubKey []byte - wantErr bool - errContains string - }{ - { - name: "happy path", - content: "hello, world", - pubKey: validPubKey, - }, - { - name: "empty content", - content: "", - pubKey: validPubKey, - }, - { - name: "invalid key - wrong length", - content: "hello", - pubKey: make([]byte, 32), - wantErr: true, - errContains: "parsing room public key", - }, - { - name: "invalid key - invalid curve point", - content: "hello", - pubKey: make([]byte, 65), // 65 zero bytes — not a valid P-256 point - wantErr: true, - errContains: "parsing room public key", - }, +func TestEncryptedMessage_JSONRoundTrip(t *testing.T) { + original := EncryptedMessage{ + Version: 7, + Nonce: []byte{4, 5, 6}, + Ciphertext: []byte{7, 8, 9}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := Encode(tt.content, tt.pubKey, 0) - if tt.wantErr { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.errContains) - assert.Nil(t, result) - return - } - require.NoError(t, err) - require.NotNil(t, result) - assert.Len(t, result.EphemeralPublicKey, 65) - assert.Len(t, result.Nonce, 12) - assert.NotEmpty(t, result.Ciphertext) - }) - } + data, err := json.Marshal(original) + require.NoError(t, err) + assert.Contains(t, string(data), `"version":7`) + assert.NotContains(t, string(data), "ephemeralPublicKey", "legacy field must not appear in JSON output") + + var decoded EncryptedMessage + require.NoError(t, json.Unmarshal(data, &decoded)) + assert.Equal(t, original, decoded) } -func TestEncode_RoundTrip(t *testing.T) { - cases := []struct { - name string - content string - }{ - {name: "non-empty", content: "hello, world"}, - {name: "empty string", content: ""}, - } +// failReader is an io.Reader that always returns an error. +type failReader struct{} - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - // Generate a fresh key pair for each subtest to ensure independence. - // Each subtest must be fully independent — no shared mutable state. - privKey, err := ecdh.P256().GenerateKey(rand.Reader) - require.NoError(t, err) - pubKeyBytes := privKey.PublicKey().Bytes() +func (f *failReader) Read(_ []byte) (int, error) { + return 0, errors.New("injected read failure") +} - msg, err := Encode(tc.content, pubKeyBytes, 0) - require.NoError(t, err) +func TestEncoder_Encode_NonDeterminism(t *testing.T) { + priv := bytes.Repeat([]byte{0x33}, 32) + enc := NewEncoder() - // Step 1: parse the ephemeral public key from the EncryptedMessage - ephPubKey, err := ecdh.P256().NewPublicKey(msg.EphemeralPublicKey) - require.NoError(t, err) + a, err := enc.Encode("room-1", "same content", priv, 1) + require.NoError(t, err) + b, err := enc.Encode("room-1", "same content", priv, 1) + require.NoError(t, err) - // Step 2: ECDH with the room private key and the ephemeral public key - sharedSecret, err := privKey.ECDH(ephPubKey) - require.NoError(t, err) + assert.False(t, bytes.Equal(a.Nonce, b.Nonce), "nonces must differ") + assert.False(t, bytes.Equal(a.Ciphertext, b.Ciphertext), "ciphertexts must differ") +} - // Step 3: re-derive the AES key using the same HKDF parameters - aesKey := make([]byte, 32) - hkdfReader := hkdf.New(sha256.New, sharedSecret, nil, []byte("room-message-encryption")) - _, err = io.ReadFull(hkdfReader, aesKey) - require.NoError(t, err) +func TestEncoder_Encode_HappyPath(t *testing.T) { + // Use a fixed 32-byte private key (a P-256 scalar's worth of entropy). + priv := make([]byte, 32) + for i := range priv { + priv[i] = byte(i + 1) + } - // Step 4: decrypt with AES-256-GCM; AAD is nil on both sides - block, err := aes.NewCipher(aesKey) - require.NoError(t, err) - gcm, err := cipher.NewGCM(block) - require.NoError(t, err) + enc := NewEncoder() + got, err := enc.Encode("room-1", "hello", priv, 7) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, 7, got.Version) + assert.Len(t, got.Nonce, 12) + assert.NotEmpty(t, got.Ciphertext) +} - // msg.Ciphertext includes the 16-byte GCM tag appended by Seal — pass it directly - plaintext, err := gcm.Open(nil, msg.Nonce, msg.Ciphertext, nil) - require.NoError(t, err) +func TestEncoder_Encode_CacheHit(t *testing.T) { + priv := bytes.Repeat([]byte{0xAB}, 32) + enc := NewEncoder() - // Note: string(nil) == "" in Go, so this assertion correctly validates that - // plaintext matches the expected content. When tc.content == "", plaintext may be nil - // but the comparison still holds due to Go's string conversion semantics. - assert.Equal(t, tc.content, string(plaintext)) - }) - } + _, err := enc.Encode("room-1", "msg1", priv, 1) + require.NoError(t, err) + _, err = enc.Encode("room-1", "msg2", priv, 1) + require.NoError(t, err) + + assert.Equal(t, 1, enc.cacheLen(), "same (roomID, version) must not re-derive the AES key") } -func TestEncode_NonDeterminism(t *testing.T) { - privKey, err := ecdh.P256().GenerateKey(rand.Reader) - require.NoError(t, err) - pubKeyBytes := privKey.PublicKey().Bytes() +func TestEncoder_Encode_DistinctVersionsCacheSeparately(t *testing.T) { + priv := bytes.Repeat([]byte{0xAB}, 32) + enc := NewEncoder() - r1, err := Encode("test message", pubKeyBytes, 0) + _, err := enc.Encode("room-1", "msg1", priv, 1) require.NoError(t, err) - r2, err := Encode("test message", pubKeyBytes, 0) + _, err = enc.Encode("room-1", "msg2", priv, 2) require.NoError(t, err) - assert.False(t, bytes.Equal(r1.EphemeralPublicKey, r2.EphemeralPublicKey), - "ephemeral public keys must differ across calls") - assert.False(t, bytes.Equal(r1.Nonce, r2.Nonce), - "nonces must differ across calls") - assert.False(t, bytes.Equal(r1.Ciphertext, r2.Ciphertext), - "ciphertexts must differ across calls (symptom of nonce reuse if equal)") - - // Guard: nonce must not be a naive truncation of the ephemeral public key - assert.False(t, bytes.Equal(r1.Nonce, r1.EphemeralPublicKey[:12]), - "nonce must not equal first 12 bytes of ephemeral public key") + assert.Equal(t, 2, enc.cacheLen(), "different versions must occupy distinct cache entries") } -func TestEncode_RandReaderErrors(t *testing.T) { - privKey, err := ecdh.P256().GenerateKey(rand.Reader) +func TestEncoder_Encode_EvictsLowestVersion(t *testing.T) { + priv := bytes.Repeat([]byte{0x42}, 32) + enc := NewEncoder(WithMaxCacheEntries(2)) + + _, err := enc.Encode("room-A", "a", priv, 1) + require.NoError(t, err) + _, err = enc.Encode("room-A", "b", priv, 2) + require.NoError(t, err) + _, err = enc.Encode("room-A", "c", priv, 3) require.NoError(t, err) - pubKeyBytes := privKey.PublicKey().Bytes() - - t.Run("ephemeral key generation fails", func(t *testing.T) { - result, err := encode("hello", pubKeyBytes, 0, &failReader{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "generating ephemeral key") - assert.Nil(t, result) - }) - - t.Run("nonce generation fails", func(t *testing.T) { - // P-256 GenerateKey reads ~50 bytes. We loop with increasing byte limits - // until ephemeral key generation succeeds but the nonce generation hits the failReader. - // - limit too small: key gen fails with "generating ephemeral key" → increase limit - // - limit just right: key gen succeeds, nonce gen fails → "generating nonce" - // - limit too large: both succeed → increase limit (encErr == nil, result != nil) - var encErr error - for limit := int64(32); limit <= 128; limit++ { - r := io.MultiReader(io.LimitReader(rand.Reader, limit), &failReader{}) - _, encErr = encode("hello", pubKeyBytes, 0, r) - if encErr != nil && strings.Contains(encErr.Error(), "generating nonce") { - break - } - } - require.Error(t, encErr) - require.Contains(t, encErr.Error(), "generating nonce", "loop exhausted without reaching nonce generation") - }) -} -func TestEncode_Version(t *testing.T) { - privKey, err := ecdh.P256().GenerateKey(rand.Reader) + assert.Equal(t, 2, enc.cacheLen(), "cache must not exceed max") + // Encoding for version 1 again must miss the cache (we evicted it), + // while versions 2 and 3 must hit. + prevLen := enc.cacheLen() + _, err = enc.Encode("room-A", "d", priv, 2) require.NoError(t, err) - pubKeyBytes := privKey.PublicKey().Bytes() + assert.Equal(t, prevLen, enc.cacheLen(), "version 2 must still be cached (hit)") - msg, err := Encode("hello", pubKeyBytes, 42) + _, err = enc.Encode("room-A", "e", priv, 1) require.NoError(t, err) - require.NotNil(t, msg) - assert.Equal(t, 42, msg.Version) + assert.Equal(t, 2, enc.cacheLen(), "after re-inserting v=1, cache stays at max (now v=2 should have been evicted as lowest)") } -func TestEncryptedMessage_JSONRoundTrip(t *testing.T) { - original := EncryptedMessage{ - Version: 7, - EphemeralPublicKey: []byte{1, 2, 3}, - Nonce: []byte{4, 5, 6}, - Ciphertext: []byte{7, 8, 9}, - } +func TestEncoder_Encode_NonceReaderError(t *testing.T) { + priv := bytes.Repeat([]byte{0xAA}, 32) + enc := NewEncoder(WithRand(&failReader{})) - data, err := json.Marshal(original) - require.NoError(t, err) - assert.Contains(t, string(data), `"version":7`) + got, err := enc.Encode("room-1", "hello", priv, 1) + require.Error(t, err) + assert.Contains(t, err.Error(), "generating nonce") + assert.Nil(t, got) +} - var decoded EncryptedMessage - require.NoError(t, json.Unmarshal(data, &decoded)) - assert.Equal(t, original, decoded) +func TestEncoder_Encode_InvalidKeyLength(t *testing.T) { + enc := NewEncoder() + got, err := enc.Encode("room-1", "hello", make([]byte, 31), 1) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be 32 bytes") + assert.Nil(t, got) } -// failReader is an io.Reader that always returns an error. -type failReader struct{} +func TestEncoder_Encode_RoundTrip(t *testing.T) { + cases := []struct { + name string + content string + }{ + {name: "non-empty", content: "hello, world"}, + {name: "empty", content: ""}, + {name: "unicode", content: "héllo 🌎"}, + } -func (f *failReader) Read(_ []byte) (int, error) { - return 0, errors.New("injected read failure") + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + priv := bytes.Repeat([]byte{0x55}, 32) + enc := NewEncoder() + msg, err := enc.Encode("room-1", tc.content, priv, 3) + require.NoError(t, err) + require.NotNil(t, msg) + + // The AES key is the room private key directly. + block, err := aes.NewCipher(priv) + require.NoError(t, err) + gcm, err := cipher.NewGCM(block) + require.NoError(t, err) + + plaintext, err := gcm.Open(nil, msg.Nonce, msg.Ciphertext, nil) + require.NoError(t, err) + assert.Equal(t, tc.content, string(plaintext)) + }) + } } diff --git a/pkg/roomcrypto/testdata/decrypt.ts b/pkg/roomcrypto/testdata/decrypt.ts index 523e9a2cf..2701c0003 100644 --- a/pkg/roomcrypto/testdata/decrypt.ts +++ b/pkg/roomcrypto/testdata/decrypt.ts @@ -1,123 +1,45 @@ -import { readFileSync } from 'fs'; - -interface EncryptedMessage { - ephemeralPublicKey: string; // base64-encoded 65-byte uncompressed P-256 point - nonce: string; // base64-encoded 12-byte AES-GCM nonce - ciphertext: string; // base64-encoded ciphertext + 16-byte GCM tag -} - -interface DecryptPayload { - privateKey: string; // base64 of 32-byte P-256 scalar (from Go privKey.Bytes()) - publicKey: string; // base64 of 65-byte uncompressed point (from Go pubKey.Bytes()) - message: EncryptedMessage; -} - -// Convert a Buffer to base64url encoding (required by the JWK spec). -function toBase64Url(buf: Buffer): string { - return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); -} - -async function decryptMessage(payload: DecryptPayload): Promise { - const privKeyBytes = Buffer.from(payload.privateKey, 'base64'); - const pubKeyBytes = Buffer.from(payload.publicKey, 'base64'); - - // Go's privKey.Bytes() is the raw 32-byte P-256 scalar. - // Go's pubKey.Bytes() is the 65-byte uncompressed point: 0x04 || x (32) || y (32). - // Web Crypto requires JWK format for private key import. - const jwkPrivate: JsonWebKey = { - kty: 'EC', - crv: 'P-256', - d: toBase64Url(privKeyBytes), // private scalar - x: toBase64Url(pubKeyBytes.slice(1, 33)), // X coordinate - y: toBase64Url(pubKeyBytes.slice(33, 65)), // Y coordinate - }; - - const roomPrivKey = await crypto.subtle.importKey( - 'jwk', - jwkPrivate, - { name: 'ECDH', namedCurve: 'P-256' }, - false, - ['deriveBits'], - ); - - // Import the ephemeral public key from the EncryptedMessage. - // Public keys must use keyUsages: [] — passing any usage throws DataError. - const ephKeyBytes = Buffer.from(payload.message.ephemeralPublicKey, 'base64'); - const jwkEph: JsonWebKey = { - kty: 'EC', - crv: 'P-256', - x: toBase64Url(ephKeyBytes.slice(1, 33)), - y: toBase64Url(ephKeyBytes.slice(33, 65)), - }; - - const ephPubKey = await crypto.subtle.importKey( - 'jwk', - jwkEph, - { name: 'ECDH', namedCurve: 'P-256' }, - false, - [], - ); - - // ECDH: derive 32-byte shared secret. - const sharedSecretBits = await crypto.subtle.deriveBits( - { name: 'ECDH', public: ephPubKey }, - roomPrivKey, - 256, - ); - - // Import the shared secret as an HKDF key. - const hkdfKey = await crypto.subtle.importKey( - 'raw', - sharedSecretBits, - 'HKDF', - false, - ['deriveKey'], - ); - - // HKDF-SHA256: derive AES-256-GCM key. - // salt=new Uint8Array(0) matches Go's nil salt — RFC 5869 §2.2: null salt ≡ zero-length salt. - const aesKey = await crypto.subtle.deriveKey( - { - name: 'HKDF', - hash: 'SHA-256', - salt: new Uint8Array(0), - info: new TextEncoder().encode('room-message-encryption'), - }, - hkdfKey, - { name: 'AES-GCM', length: 256 }, - false, - ['decrypt'], - ); - - // AES-256-GCM decrypt. - // ciphertext already includes the 16-byte GCM tag appended by Go's gcm.Seal. - // AAD is omitted — matches Go's nil AAD. - const nonce = Buffer.from(payload.message.nonce, 'base64'); - const ciphertext = Buffer.from(payload.message.ciphertext, 'base64'); - - const plaintext = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv: nonce }, - aesKey, - ciphertext, - ); - - return new TextDecoder().decode(plaintext); -} - -async function main(): Promise { - const payloadPath = process.argv[2]; - if (!payloadPath) { - process.stderr.write('usage: tsx decrypt.ts \n'); - process.exit(1); +// decrypt.ts — invoked by integration_test.go via tsx +import { createDecipheriv } from 'node:crypto' + +type Payload = { + privateKey: string // base64 32-byte raw private scalar (high-entropy IKM) + message: { + version: number + nonce: string // base64 + ciphertext: string // base64 = content || 16-byte GCM tag } +} - const payload: DecryptPayload = JSON.parse(readFileSync(payloadPath, 'utf8')); - const plaintext = await decryptMessage(payload); - // Use process.stdout.write (not console.log) to avoid adding an extra newline. - process.stdout.write(plaintext); +async function main() { + const raw = await new Promise((resolve, reject) => { + let chunks = '' + process.stdin.setEncoding('utf-8') + process.stdin.on('data', (c) => (chunks += c)) + process.stdin.on('end', () => resolve(chunks)) + process.stdin.on('error', reject) + }) + + const p = JSON.parse(raw) as Payload + const privateKey = Buffer.from(p.privateKey, 'base64') + if (privateKey.length !== 32) throw new Error(`expected 32-byte private key, got ${privateKey.length}`) + + const aesKey = privateKey // already 32 bytes; used directly as AES-256 key + const nonce = Buffer.from(p.message.nonce, 'base64') + const ciphertext = Buffer.from(p.message.ciphertext, 'base64') + if (nonce.length !== 12) throw new Error(`expected 12-byte nonce, got ${nonce.length}`) + if (ciphertext.length < 16) throw new Error('ciphertext must include a 16-byte GCM tag') + + // Node's createDecipheriv expects ciphertext and auth tag separately. + const tag = ciphertext.subarray(ciphertext.length - 16) + const body = ciphertext.subarray(0, ciphertext.length - 16) + + const decipher = createDecipheriv('aes-256-gcm', aesKey, nonce) + decipher.setAuthTag(tag) + const plaintext = Buffer.concat([decipher.update(body), decipher.final()]) + process.stdout.write(plaintext.toString('utf-8')) } -main().catch((err: unknown) => { - process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`); - process.exit(1); -}); +main().catch((err) => { + process.stderr.write(`${err.stack ?? err.message ?? err}\n`) + process.exit(1) +}) diff --git a/pkg/roomkeysender/integration_test.go b/pkg/roomkeysender/integration_test.go index efd1210e5..20ab96059 100644 --- a/pkg/roomkeysender/integration_test.go +++ b/pkg/roomkeysender/integration_test.go @@ -5,7 +5,6 @@ package roomkeysender import ( "bytes" "context" - "crypto/ecdh" "crypto/rand" "encoding/json" "fmt" @@ -249,11 +248,10 @@ func TestRoomKeySender_TypeScriptClient(t *testing.T) { nc, wsURL := setupNATS(t, nw) nodeContainer := setupNode(t, nw) - // 2. Generate a fresh P-256 key pair. - privKey, err := ecdh.P256().GenerateKey(rand.Reader) + // 2. Generate a fresh 32-byte room secret. + privKeyBytes := make([]byte, 32) + _, err := rand.Read(privKeyBytes) require.NoError(t, err) - pubKeyBytes := privKey.PublicKey().Bytes() - privKeyBytes := privKey.Bytes() // 3. Test parameters. account := "alice" @@ -292,7 +290,6 @@ func TestRoomKeySender_TypeScriptClient(t *testing.T) { evt := &model.RoomKeyEvent{ RoomID: roomID, Version: version, - PublicKey: pubKeyBytes, PrivateKey: privKeyBytes, } err = sender.Send(account, *evt) @@ -301,8 +298,10 @@ func TestRoomKeySender_TypeScriptClient(t *testing.T) { // 7. Small delay to ensure key is received before the encrypted message. time.Sleep(500 * time.Millisecond) - // 8. Encrypt a message with the room public key. - encrypted, err := roomcrypto.Encode(plaintext, pubKeyBytes, version) + // 8. Encrypt a message using the Encoder (room private key used directly + // as AES-256-GCM key — no key derivation step). + encoder := roomcrypto.NewEncoder() + encrypted, err := encoder.Encode(roomID, plaintext, privKeyBytes, version) require.NoError(t, err, "encrypt message") encryptedJSON, err := json.Marshal(encrypted) require.NoError(t, err, "marshal encrypted message") diff --git a/pkg/roomkeysender/roomkeysender_test.go b/pkg/roomkeysender/roomkeysender_test.go index c8fdcda1b..e1d184bd9 100644 --- a/pkg/roomkeysender/roomkeysender_test.go +++ b/pkg/roomkeysender/roomkeysender_test.go @@ -26,11 +26,6 @@ func (m *mockPublisher) Publish(subject string, data []byte) error { } func TestSender_Send(t *testing.T) { - pub65 := make([]byte, 65) - pub65[0] = 0x04 - for i := 1; i < 65; i++ { - pub65[i] = byte(i) - } priv32 := make([]byte, 32) for i := range priv32 { priv32[i] = byte(i + 100) @@ -50,7 +45,6 @@ func TestSender_Send(t *testing.T) { evt: model.RoomKeyEvent{ RoomID: "room-1", Version: 0, - PublicKey: pub65, PrivateKey: priv32, }, wantSubj: "chat.user.alice.event.room.key", @@ -61,7 +55,6 @@ func TestSender_Send(t *testing.T) { evt: model.RoomKeyEvent{ RoomID: "room-2", Version: 1, - PublicKey: []byte{0x04, 0x01}, PrivateKey: []byte{0x0a}, }, wantSubj: "chat.user.bob.event.room.key", @@ -72,7 +65,6 @@ func TestSender_Send(t *testing.T) { evt: model.RoomKeyEvent{ RoomID: "room-3", Version: 2, - PublicKey: []byte{0x04}, PrivateKey: []byte{0x01}, }, publishErr: errors.New("connection lost"), @@ -83,11 +75,10 @@ func TestSender_Send(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Deep-copy the caller's event for the post-call non-mutation check: - // the shallow struct copy alone would share PublicKey / PrivateKey - // backing arrays with tt.evt, so an in-place slice mutation by Send - // would be invisible to a plain assert.Equal(before, tt.evt). + // the shallow struct copy alone would share PrivateKey backing array + // with tt.evt, so an in-place slice mutation by Send would be + // invisible to a plain assert.Equal(before, tt.evt). before := tt.evt - before.PublicKey = append([]byte(nil), tt.evt.PublicKey...) before.PrivateKey = append([]byte(nil), tt.evt.PrivateKey...) pub := &mockPublisher{err: tt.publishErr} @@ -114,7 +105,6 @@ func TestSender_Send(t *testing.T) { require.NoError(t, json.Unmarshal(pub.data, &got)) assert.Equal(t, tt.evt.RoomID, got.RoomID) assert.Equal(t, tt.evt.Version, got.Version) - assert.Equal(t, tt.evt.PublicKey, got.PublicKey) assert.Equal(t, tt.evt.PrivateKey, got.PrivateKey) assert.Greater(t, got.Timestamp, int64(0)) }) diff --git a/pkg/roomkeysender/testdata/client.ts b/pkg/roomkeysender/testdata/client.ts index a957fc2ac..d85926cf9 100644 --- a/pkg/roomkeysender/testdata/client.ts +++ b/pkg/roomkeysender/testdata/client.ts @@ -5,92 +5,33 @@ import { connect, type NatsConnection, type Msg } from "nats.ws"; interface RoomKeyEvent { roomId: string; version: number; - publicKey: string; // base64-encoded 65-byte uncompressed P-256 point - privateKey: string; // base64-encoded 32-byte scalar + privateKey: string; // base64-encoded 32-byte room secret, used directly as AES-256 key } interface EncryptedMessage { - ephemeralPublicKey: string; // base64-encoded 65-byte uncompressed P-256 point - nonce: string; // base64-encoded 12-byte AES-GCM nonce - ciphertext: string; // base64-encoded ciphertext + 16-byte GCM tag -} - -// ---- Helpers ---- - -function toBase64Url(buf: Buffer): string { - return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + version: number; + nonce: string; // base64-encoded 12-byte AES-GCM nonce + ciphertext: string; // base64-encoded ciphertext + 16-byte GCM tag } -// ---- Decryption (matches pkg/roomcrypto algorithm) ---- +// ---- Decryption (matches pkg/roomcrypto direct-import scheme) ---- async function decryptMessage( encrypted: EncryptedMessage, roomPrivateKeyB64: string, - roomPublicKeyB64: string, ): Promise { const privKeyBytes = Buffer.from(roomPrivateKeyB64, "base64"); - const pubKeyBytes = Buffer.from(roomPublicKeyB64, "base64"); - - // Import room private key as JWK for ECDH. - const jwkPrivate: JsonWebKey = { - kty: "EC", - crv: "P-256", - d: toBase64Url(privKeyBytes), - x: toBase64Url(pubKeyBytes.slice(1, 33)), - y: toBase64Url(pubKeyBytes.slice(33, 65)), - }; - - const roomPrivKey = await crypto.subtle.importKey( - "jwk", - jwkPrivate, - { name: "ECDH", namedCurve: "P-256" }, - false, - ["deriveBits"], - ); - - // Import ephemeral public key from encrypted message. - const ephKeyBytes = Buffer.from(encrypted.ephemeralPublicKey, "base64"); - const jwkEph: JsonWebKey = { - kty: "EC", - crv: "P-256", - x: toBase64Url(ephKeyBytes.slice(1, 33)), - y: toBase64Url(ephKeyBytes.slice(33, 65)), - }; - - const ephPubKey = await crypto.subtle.importKey( - "jwk", - jwkEph, - { name: "ECDH", namedCurve: "P-256" }, - false, - [], - ); - - // ECDH: derive shared secret. - const sharedSecretBits = await crypto.subtle.deriveBits( - { name: "ECDH", public: ephPubKey }, - roomPrivKey, - 256, - ); - // HKDF-SHA256: derive AES-256-GCM key. - const hkdfKey = await crypto.subtle.importKey("raw", sharedSecretBits, "HKDF", false, [ - "deriveKey", - ]); - - const aesKey = await crypto.subtle.deriveKey( - { - name: "HKDF", - hash: "SHA-256", - salt: new Uint8Array(0), - info: new TextEncoder().encode("room-message-encryption"), - }, - hkdfKey, + // Import room private key directly as AES-256-GCM key (no HKDF step). + const aesKey = await crypto.subtle.importKey( + "raw", + privKeyBytes, { name: "AES-GCM", length: 256 }, false, ["decrypt"], ); - // AES-256-GCM decrypt. + // AES-256-GCM decrypt (ciphertext includes the 16-byte GCM tag appended). const nonce = Buffer.from(encrypted.nonce, "base64"); const ciphertext = Buffer.from(encrypted.ciphertext, "base64"); @@ -116,7 +57,7 @@ async function main(): Promise { const msgSubject = `test.room.${roomID}.msg`; // Store received keys indexed by version number. - const keys = new Map(); + const keys = new Map(); const nc: NatsConnection = await connect({ servers: natsURL }); @@ -129,7 +70,7 @@ async function main(): Promise { (async () => { for await (const msg of keySub) { const evt: RoomKeyEvent = JSON.parse(new TextDecoder().decode(msg.data)); - keys.set(evt.version, { publicKey: evt.publicKey, privateKey: evt.privateKey }); + keys.set(evt.version, evt.privateKey); } })(); @@ -143,14 +84,14 @@ async function main(): Promise { } const version = parseInt(versionStr, 10); - const keyPair = keys.get(version); - if (!keyPair) { + const privateKey = keys.get(version); + if (!privateKey) { process.stderr.write(`no key found for version ${version}\n`); process.exit(1); } const encrypted: EncryptedMessage = JSON.parse(new TextDecoder().decode(msg.data)); - const plaintext = await decryptMessage(encrypted, keyPair.privateKey, keyPair.publicKey); + const plaintext = await decryptMessage(encrypted, privateKey); process.stdout.write(plaintext); break; diff --git a/pkg/roomkeystore/adapter.go b/pkg/roomkeystore/adapter.go index c3c4e600d..491af187c 100644 --- a/pkg/roomkeystore/adapter.go +++ b/pkg/roomkeystore/adapter.go @@ -15,12 +15,12 @@ type clusterAdapter struct { c *redis.ClusterClient } -func (a *clusterAdapter) hset(ctx context.Context, key string, pub, priv string) error { - return a.c.HSet(ctx, key, "pub", pub, "priv", priv, "ver", "0").Err() +func (a *clusterAdapter) hset(ctx context.Context, key string, priv string) error { + return a.c.HSet(ctx, key, "priv", priv, "ver", "0").Err() } -func (a *clusterAdapter) hsetWithVersion(ctx context.Context, key string, pub, priv string, version int) error { - return a.c.HSet(ctx, key, "pub", pub, "priv", priv, "ver", strconv.Itoa(version)).Err() +func (a *clusterAdapter) hsetWithVersion(ctx context.Context, key string, priv string, version int) error { + return a.c.HSet(ctx, key, "priv", priv, "ver", strconv.Itoa(version)).Err() } func (a *clusterAdapter) hgetall(ctx context.Context, key string) (map[string]string, error) { @@ -34,9 +34,8 @@ func (a *clusterAdapter) hgetall(ctx context.Context, key string) (map[string]st var rotateScript = redis.NewScript(` local currentKey = KEYS[1] local prevKey = KEYS[2] -local newPub = ARGV[1] -local newPriv = ARGV[2] -local graceSec = tonumber(ARGV[3]) +local newPriv = ARGV[1] +local graceSec = tonumber(ARGV[2]) local cur = redis.call('HGETALL', currentKey) if #cur == 0 then @@ -50,16 +49,16 @@ redis.call('DEL', prevKey) redis.call('HSET', prevKey, unpack(cur)) redis.call('EXPIRE', prevKey, graceSec) -redis.call('HSET', currentKey, 'pub', newPub, 'priv', newPriv, 'ver', tostring(newVer)) +redis.call('HSET', currentKey, 'priv', newPriv, 'ver', tostring(newVer)) return newVer `) -func (a *clusterAdapter) rotatePipeline(ctx context.Context, currentKey, prevKey string, pub, priv string, gracePeriod time.Duration) (int, error) { +func (a *clusterAdapter) rotatePipeline(ctx context.Context, currentKey, prevKey string, priv string, gracePeriod time.Duration) (int, error) { graceSec := int(gracePeriod.Seconds()) if graceSec < 1 { graceSec = 1 } - result, err := rotateScript.Run(ctx, a.c, []string{currentKey, prevKey}, pub, priv, graceSec).Int() + result, err := rotateScript.Run(ctx, a.c, []string{currentKey, prevKey}, priv, graceSec).Int() if isLuaNoCurrentKeyErr(err) { return 0, ErrNoCurrentKey } diff --git a/pkg/roomkeystore/doc.go b/pkg/roomkeystore/doc.go index abc572f4f..52f68e161 100644 --- a/pkg/roomkeystore/doc.go +++ b/pkg/roomkeystore/doc.go @@ -1,4 +1,4 @@ -// Package roomkeystore stores room encryption key pairs in Valkey. +// Package roomkeystore stores room encryption secrets in Valkey. // // # Versioning // diff --git a/pkg/roomkeystore/integration_test.go b/pkg/roomkeystore/integration_test.go index 1e42d81b9..cd2bb12c4 100644 --- a/pkg/roomkeystore/integration_test.go +++ b/pkg/roomkeystore/integration_test.go @@ -34,9 +34,8 @@ func TestValkeyStore_Integration_RoundTrip(t *testing.T) { store, _ := setupValkey(t, time.Hour) ctx := context.Background() - pubKey := bytes.Repeat([]byte{0xAB}, 65) privKey := bytes.Repeat([]byte{0xCD}, 32) - pair := RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey} + pair := RoomKeyPair{PrivateKey: privKey} ver, err := store.Set(ctx, "room-1", pair) require.NoError(t, err) @@ -46,7 +45,6 @@ func TestValkeyStore_Integration_RoundTrip(t *testing.T) { require.NoError(t, err) require.NotNil(t, got) assert.Equal(t, 0, got.Version) - assert.Equal(t, pubKey, got.KeyPair.PublicKey) assert.Equal(t, privKey, got.KeyPair.PrivateKey) err = store.Delete(ctx, "room-1") @@ -61,9 +59,8 @@ func TestValkeyStore_Integration_SetWithVersion(t *testing.T) { store, _ := setupValkey(t, time.Hour) ctx := context.Background() - pubKey := bytes.Repeat([]byte{0xAB}, 65) privKey := bytes.Repeat([]byte{0xCD}, 32) - pair := RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey} + pair := RoomKeyPair{PrivateKey: privKey} require.NoError(t, store.SetWithVersion(ctx, "room-replicated", pair, 7)) @@ -71,16 +68,16 @@ func TestValkeyStore_Integration_SetWithVersion(t *testing.T) { require.NoError(t, err) require.NotNil(t, got) assert.Equal(t, 7, got.Version, "version must match the caller-supplied value") - assert.Equal(t, pubKey, got.KeyPair.PublicKey) assert.Equal(t, privKey, got.KeyPair.PrivateKey) - newPub := bytes.Repeat([]byte{0xEE}, 65) - require.NoError(t, store.SetWithVersion(ctx, "room-replicated", RoomKeyPair{PublicKey: newPub, PrivateKey: privKey}, 9)) + // Overwriting at a higher version is allowed (idempotent for replication catch-up). + newPriv := bytes.Repeat([]byte{0xEE}, 32) + require.NoError(t, store.SetWithVersion(ctx, "room-replicated", RoomKeyPair{PrivateKey: newPriv}, 9)) got, err = store.Get(ctx, "room-replicated") require.NoError(t, err) require.NotNil(t, got) assert.Equal(t, 9, got.Version) - assert.Equal(t, newPub, got.KeyPair.PublicKey) + assert.Equal(t, newPriv, got.KeyPair.PrivateKey) } func TestValkeyStore_Integration_MissingKey(t *testing.T) { @@ -96,16 +93,16 @@ func TestValkeyStore_Integration_RotateRoundTrip(t *testing.T) { store, _ := setupValkey(t, time.Hour) ctx := context.Background() - oldPub := bytes.Repeat([]byte{0xAA}, 65) oldPriv := bytes.Repeat([]byte{0xBB}, 32) - newPub := bytes.Repeat([]byte{0xCC}, 65) newPriv := bytes.Repeat([]byte{0xDD}, 32) - ver, err := store.Set(ctx, "room-rot", RoomKeyPair{PublicKey: oldPub, PrivateKey: oldPriv}) + // Set initial key pair. + ver, err := store.Set(ctx, "room-rot", RoomKeyPair{PrivateKey: oldPriv}) require.NoError(t, err) assert.Equal(t, 0, ver) - ver, err = store.Rotate(ctx, "room-rot", RoomKeyPair{PublicKey: newPub, PrivateKey: newPriv}) + // Rotate to new key pair. + ver, err = store.Rotate(ctx, "room-rot", RoomKeyPair{PrivateKey: newPriv}) require.NoError(t, err) assert.Equal(t, 1, ver) @@ -113,19 +110,16 @@ func TestValkeyStore_Integration_RotateRoundTrip(t *testing.T) { require.NoError(t, err) require.NotNil(t, got) assert.Equal(t, 1, got.Version) - assert.Equal(t, newPub, got.KeyPair.PublicKey) assert.Equal(t, newPriv, got.KeyPair.PrivateKey) oldPair, err := store.GetByVersion(ctx, "room-rot", 0) require.NoError(t, err) require.NotNil(t, oldPair) - assert.Equal(t, oldPub, oldPair.PublicKey) assert.Equal(t, oldPriv, oldPair.PrivateKey) newPair, err := store.GetByVersion(ctx, "room-rot", 1) require.NoError(t, err) require.NotNil(t, newPair) - assert.Equal(t, newPub, newPair.PublicKey) assert.Equal(t, newPriv, newPair.PrivateKey) unknown, err := store.GetByVersion(ctx, "room-rot", 999) @@ -137,15 +131,13 @@ func TestValkeyStore_Integration_GracePeriodExpiry(t *testing.T) { store, _ := setupValkey(t, 1*time.Second) ctx := context.Background() - oldPub := bytes.Repeat([]byte{0x01}, 65) oldPriv := bytes.Repeat([]byte{0x02}, 32) - newPub := bytes.Repeat([]byte{0x03}, 65) newPriv := bytes.Repeat([]byte{0x04}, 32) - _, err := store.Set(ctx, "room-grace", RoomKeyPair{PublicKey: oldPub, PrivateKey: oldPriv}) + _, err := store.Set(ctx, "room-grace", RoomKeyPair{PrivateKey: oldPriv}) require.NoError(t, err) - _, err = store.Rotate(ctx, "room-grace", RoomKeyPair{PublicKey: newPub, PrivateKey: newPriv}) + _, err = store.Rotate(ctx, "room-grace", RoomKeyPair{PrivateKey: newPriv}) require.NoError(t, err) oldPair, err := store.GetByVersion(ctx, "room-grace", 0) @@ -171,7 +163,6 @@ func TestValkeyStore_Integration_RotateNoCurrentKey(t *testing.T) { ctx := context.Background() _, err := store.Rotate(ctx, "room-empty", RoomKeyPair{ - PublicKey: bytes.Repeat([]byte{0x01}, 65), PrivateKey: bytes.Repeat([]byte{0x02}, 32), }) require.Error(t, err) @@ -183,13 +174,11 @@ func TestValkeyStore_Integration_DeleteBothKeys(t *testing.T) { ctx := context.Background() _, err := store.Set(ctx, "room-del", RoomKeyPair{ - PublicKey: bytes.Repeat([]byte{0xAA}, 65), PrivateKey: bytes.Repeat([]byte{0xBB}, 32), }) require.NoError(t, err) _, err = store.Rotate(ctx, "room-del", RoomKeyPair{ - PublicKey: bytes.Repeat([]byte{0xCC}, 65), PrivateKey: bytes.Repeat([]byte{0xDD}, 32), }) require.NoError(t, err) @@ -210,18 +199,15 @@ func TestValkeyStore_Integration_GetMany(t *testing.T) { store, _ := setupValkey(t, time.Hour) ctx := context.Background() - pub1 := bytes.Repeat([]byte{0x01}, 65) priv1 := bytes.Repeat([]byte{0x02}, 32) - pub2 := bytes.Repeat([]byte{0x03}, 65) priv2 := bytes.Repeat([]byte{0x04}, 32) - pub3 := bytes.Repeat([]byte{0x05}, 65) priv3 := bytes.Repeat([]byte{0x06}, 32) - _, err := store.Set(ctx, "getmany-room-1", RoomKeyPair{PublicKey: pub1, PrivateKey: priv1}) + _, err := store.Set(ctx, "getmany-room-1", RoomKeyPair{PrivateKey: priv1}) require.NoError(t, err) - _, err = store.Set(ctx, "getmany-room-2", RoomKeyPair{PublicKey: pub2, PrivateKey: priv2}) + _, err = store.Set(ctx, "getmany-room-2", RoomKeyPair{PrivateKey: priv2}) require.NoError(t, err) - _, err = store.Set(ctx, "getmany-room-3", RoomKeyPair{PublicKey: pub3, PrivateKey: priv3}) + _, err = store.Set(ctx, "getmany-room-3", RoomKeyPair{PrivateKey: priv3}) require.NoError(t, err) got, err := store.GetMany(ctx, []string{"getmany-room-1", "getmany-room-2", "getmany-room-3", "getmany-room-missing"}) @@ -230,17 +216,14 @@ func TestValkeyStore_Integration_GetMany(t *testing.T) { require.Contains(t, got, "getmany-room-1") assert.Equal(t, 0, got["getmany-room-1"].Version) - assert.Equal(t, pub1, got["getmany-room-1"].KeyPair.PublicKey) assert.Equal(t, priv1, got["getmany-room-1"].KeyPair.PrivateKey) require.Contains(t, got, "getmany-room-2") assert.Equal(t, 0, got["getmany-room-2"].Version) - assert.Equal(t, pub2, got["getmany-room-2"].KeyPair.PublicKey) assert.Equal(t, priv2, got["getmany-room-2"].KeyPair.PrivateKey) require.Contains(t, got, "getmany-room-3") assert.Equal(t, 0, got["getmany-room-3"].Version) - assert.Equal(t, pub3, got["getmany-room-3"].KeyPair.PublicKey) assert.Equal(t, priv3, got["getmany-room-3"].KeyPair.PrivateKey) _, missing := got["getmany-room-missing"] @@ -273,13 +256,11 @@ func TestValkeyStore_Integration_HashTagSlotConsistency(t *testing.T) { "current key %q and prev key %q must hash to the same cluster slot", currentKey, prevKey) _, err = store.Set(ctx, roomID, RoomKeyPair{ - PublicKey: bytes.Repeat([]byte{0x01}, 65), PrivateKey: bytes.Repeat([]byte{0x02}, 32), }) require.NoError(t, err) _, err = store.Rotate(ctx, roomID, RoomKeyPair{ - PublicKey: bytes.Repeat([]byte{0x03}, 65), PrivateKey: bytes.Repeat([]byte{0x04}, 32), }) require.NoError(t, err, "rotate must not return CROSSSLOT — hash tags ensure both keys share a slot") diff --git a/pkg/roomkeystore/keygen.go b/pkg/roomkeystore/keygen.go index 064aa7c4c..9975a89c7 100644 --- a/pkg/roomkeystore/keygen.go +++ b/pkg/roomkeystore/keygen.go @@ -1,19 +1,18 @@ package roomkeystore import ( - "crypto/ecdh" "crypto/rand" "fmt" ) -// GenerateKeyPair returns a fresh P-256 keypair for a room. -func GenerateKeyPair() (RoomKeyPair, error) { - priv, err := ecdh.P256().GenerateKey(rand.Reader) - if err != nil { - return RoomKeyPair{}, fmt.Errorf("generate P-256 key: %w", err) +// GenerateKeyPair returns a fresh 32-byte room secret used by roomcrypto +// directly as an AES-256-GCM key (no key derivation step). The name retains +// "KeyPair" for source compatibility with existing call sites; cryptographically +// this is a single symmetric secret, not an asymmetric keypair. +func GenerateKeyPair() (*RoomKeyPair, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return nil, fmt.Errorf("generate room key: %w", err) } - return RoomKeyPair{ - PublicKey: priv.PublicKey().Bytes(), - PrivateKey: priv.Bytes(), - }, nil + return &RoomKeyPair{PrivateKey: buf}, nil } diff --git a/pkg/roomkeystore/keygen_test.go b/pkg/roomkeystore/keygen_test.go index da4cf2234..4eca039cc 100644 --- a/pkg/roomkeystore/keygen_test.go +++ b/pkg/roomkeystore/keygen_test.go @@ -2,25 +2,17 @@ package roomkeystore_test import ( "bytes" - "crypto/aes" - "crypto/cipher" - "crypto/ecdh" - "crypto/sha256" - "io" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "golang.org/x/crypto/hkdf" - "github.com/hmchangw/chat/pkg/roomcrypto" "github.com/hmchangw/chat/pkg/roomkeystore" ) func TestGenerateKeyPair_Shape(t *testing.T) { pair, err := roomkeystore.GenerateKeyPair() require.NoError(t, err) - assert.Len(t, pair.PublicKey, 65) assert.Len(t, pair.PrivateKey, 32) } @@ -29,41 +21,5 @@ func TestGenerateKeyPair_Distinct(t *testing.T) { require.NoError(t, err) b, err := roomkeystore.GenerateKeyPair() require.NoError(t, err) - assert.False(t, bytes.Equal(a.PublicKey, b.PublicKey)) assert.False(t, bytes.Equal(a.PrivateKey, b.PrivateKey)) } - -// Exercises the full encrypt-then-decrypt path so a generator returning mismatched halves would fail. -func TestGenerateKeyPair_RoundTripWithRoomcrypto(t *testing.T) { - pair, err := roomkeystore.GenerateKeyPair() - require.NoError(t, err) - - const plaintext = "hello" - encrypted, err := roomcrypto.Encode(plaintext, pair.PublicKey, 0) - require.NoError(t, err) - - got := decryptForTest(t, encrypted, pair.PrivateKey) - assert.Equal(t, plaintext, got, "round-trip must succeed when private and public halves match") -} - -func decryptForTest(t *testing.T, em *roomcrypto.EncryptedMessage, roomPriv []byte) string { - t.Helper() - priv, err := ecdh.P256().NewPrivateKey(roomPriv) - require.NoError(t, err) - ephPub, err := ecdh.P256().NewPublicKey(em.EphemeralPublicKey) - require.NoError(t, err) - shared, err := priv.ECDH(ephPub) - require.NoError(t, err) - - aesKey := make([]byte, 32) - _, err = io.ReadFull(hkdf.New(sha256.New, shared, nil, []byte("room-message-encryption")), aesKey) - require.NoError(t, err) - - block, err := aes.NewCipher(aesKey) - require.NoError(t, err) - gcm, err := cipher.NewGCM(block) - require.NoError(t, err) - plain, err := gcm.Open(nil, em.Nonce, em.Ciphertext, nil) - require.NoError(t, err) - return string(plain) -} diff --git a/pkg/roomkeystore/roomkeystore.go b/pkg/roomkeystore/roomkeystore.go index a5931c71b..0c203375c 100644 --- a/pkg/roomkeystore/roomkeystore.go +++ b/pkg/roomkeystore/roomkeystore.go @@ -13,10 +13,9 @@ import ( // ErrNoCurrentKey is returned by Rotate when no current key exists for the room. var ErrNoCurrentKey = errors.New("no current key") -// RoomKeyPair holds the raw P-256 key bytes for a room. +// RoomKeyPair holds the 32-byte room secret used directly as the AES-256-GCM key by roomcrypto. type RoomKeyPair struct { - PublicKey []byte // 65-byte uncompressed point - PrivateKey []byte // 32-byte scalar + PrivateKey []byte // 32-byte secret; used directly as AES-256-GCM key material } // VersionedKeyPair pairs a key pair with its store-assigned version number. @@ -25,7 +24,7 @@ type VersionedKeyPair struct { KeyPair RoomKeyPair } -// RoomKeyStore defines storage operations for room encryption key pairs. +// RoomKeyStore defines storage operations for room encryption secrets. type RoomKeyStore interface { Set(ctx context.Context, roomID string, pair RoomKeyPair) (int, error) // SetWithVersion overwrites the current key for roomID with pair stamped at the @@ -44,11 +43,11 @@ type RoomKeyStore interface { // hashCommander is a minimal internal interface over the Valkey hash commands used by valkeyStore. // Unexported and command-specific so unit tests can inject a fake without a live Valkey connection. type hashCommander interface { - hset(ctx context.Context, key string, pub, priv string) error - hsetWithVersion(ctx context.Context, key string, pub, priv string, version int) error + hset(ctx context.Context, key string, priv string) error + hsetWithVersion(ctx context.Context, key string, priv string, version int) error hgetall(ctx context.Context, key string) (map[string]string, error) hgetallMany(ctx context.Context, keys []string) ([]map[string]string, error) - rotatePipeline(ctx context.Context, currentKey, prevKey string, pub, priv string, gracePeriod time.Duration) (int, error) + rotatePipeline(ctx context.Context, currentKey, prevKey string, priv string, gracePeriod time.Duration) (int, error) deletePipeline(ctx context.Context, currentKey, prevKey string) error closeClient() error } @@ -83,10 +82,9 @@ func roomprevkey(roomID string) string { // Set stores pair in Valkey as a hash with no TTL, assigning version 0. // Does not touch the previous key slot. func (s *valkeyStore) Set(ctx context.Context, roomID string, pair RoomKeyPair) (int, error) { - pub := base64.StdEncoding.EncodeToString(pair.PublicKey) priv := base64.StdEncoding.EncodeToString(pair.PrivateKey) key := roomkey(roomID) - if err := s.client.hset(ctx, key, pub, priv); err != nil { + if err := s.client.hset(ctx, key, priv); err != nil { return 0, fmt.Errorf("set room key: %w", err) } return 0, nil @@ -98,9 +96,8 @@ func (s *valkeyStore) Set(ctx context.Context, roomID string, pair RoomKeyPair) // message envelopes regardless of which site broadcast the message. Does not // touch the previous key slot. func (s *valkeyStore) SetWithVersion(ctx context.Context, roomID string, pair RoomKeyPair, version int) error { - pub := base64.StdEncoding.EncodeToString(pair.PublicKey) priv := base64.StdEncoding.EncodeToString(pair.PrivateKey) - if err := s.client.hsetWithVersion(ctx, roomkey(roomID), pub, priv, version); err != nil { + if err := s.client.hsetWithVersion(ctx, roomkey(roomID), priv, version); err != nil { return fmt.Errorf("set room key with version %d: %w", version, err) } return nil @@ -200,9 +197,8 @@ func (s *valkeyStore) GetByVersion(ctx context.Context, roomID string, version i // increments the version, and writes newPair as the current key. // Returns the new version number. Returns ErrNoCurrentKey if no current key exists. func (s *valkeyStore) Rotate(ctx context.Context, roomID string, newPair RoomKeyPair) (int, error) { - pub := base64.StdEncoding.EncodeToString(newPair.PublicKey) priv := base64.StdEncoding.EncodeToString(newPair.PrivateKey) - version, err := s.client.rotatePipeline(ctx, roomkey(roomID), roomprevkey(roomID), pub, priv, s.gracePeriod) + version, err := s.client.rotatePipeline(ctx, roomkey(roomID), roomprevkey(roomID), priv, s.gracePeriod) if err != nil { return 0, fmt.Errorf("rotate room key: %w", err) } @@ -218,15 +214,21 @@ func (s *valkeyStore) Delete(ctx context.Context, roomID string) error { return nil } -// decodeKeyPair decodes base64-encoded pub and priv fields from a Valkey hash. +// decodeKeyPair decodes the base64-encoded priv field from a Valkey hash. +// Old Valkey rows may still carry a "pub" field from the legacy P-256 keypair +// layout; we ignore it. The room secret is the 32-byte uniform-random key used +// directly as the AES-256-GCM key. func decodeKeyPair(fields map[string]string) (*RoomKeyPair, error) { - pub, err := base64.StdEncoding.DecodeString(fields["pub"]) - if err != nil { - return nil, fmt.Errorf("decode public key: %w", err) + encodedPriv, ok := fields["priv"] + if !ok || encodedPriv == "" { + return nil, fmt.Errorf("decode private key: missing priv field") } - priv, err := base64.StdEncoding.DecodeString(fields["priv"]) + priv, err := base64.StdEncoding.DecodeString(encodedPriv) if err != nil { return nil, fmt.Errorf("decode private key: %w", err) } - return &RoomKeyPair{PublicKey: pub, PrivateKey: priv}, nil + if len(priv) != 32 { + return nil, fmt.Errorf("decode private key: invalid length %d", len(priv)) + } + return &RoomKeyPair{PrivateKey: priv}, nil } diff --git a/pkg/roomkeystore/roomkeystore_test.go b/pkg/roomkeystore/roomkeystore_test.go index 76f890070..968fb8c24 100644 --- a/pkg/roomkeystore/roomkeystore_test.go +++ b/pkg/roomkeystore/roomkeystore_test.go @@ -27,25 +27,25 @@ type fakeHashClient struct { closed bool } -func (f *fakeHashClient) hset(_ context.Context, key string, pub, priv string) error { +func (f *fakeHashClient) hset(_ context.Context, key string, priv string) error { if f.hsetErr != nil { return f.hsetErr } if f.store == nil { f.store = make(map[string]map[string]string) } - f.store[key] = map[string]string{"pub": pub, "priv": priv, "ver": "0"} + f.store[key] = map[string]string{"priv": priv, "ver": "0"} return nil } -func (f *fakeHashClient) hsetWithVersion(_ context.Context, key string, pub, priv string, version int) error { +func (f *fakeHashClient) hsetWithVersion(_ context.Context, key string, priv string, version int) error { if f.hsetErr != nil { return f.hsetErr } if f.store == nil { f.store = make(map[string]map[string]string) } - f.store[key] = map[string]string{"pub": pub, "priv": priv, "ver": strconv.Itoa(version)} + f.store[key] = map[string]string{"priv": priv, "ver": strconv.Itoa(version)} return nil } @@ -77,7 +77,7 @@ func (f *fakeHashClient) hgetallMany(ctx context.Context, keys []string) ([]map[ return out, nil } -func (f *fakeHashClient) rotatePipeline(_ context.Context, currentKey, prevKey string, pub, priv string, _ time.Duration) (int, error) { +func (f *fakeHashClient) rotatePipeline(_ context.Context, currentKey, prevKey string, priv string, _ time.Duration) (int, error) { if f.rotatePipelineErr != nil { return 0, f.rotatePipelineErr } @@ -91,9 +91,9 @@ func (f *fakeHashClient) rotatePipeline(_ context.Context, currentKey, prevKey s curVer, _ := strconv.Atoi(cur["ver"]) newVer := curVer + 1 // Copy current to prev. - f.store[prevKey] = map[string]string{"pub": cur["pub"], "priv": cur["priv"], "ver": cur["ver"]} + f.store[prevKey] = map[string]string{"priv": cur["priv"], "ver": cur["ver"]} // Write new current. - f.store[currentKey] = map[string]string{"pub": pub, "priv": priv, "ver": strconv.Itoa(newVer)} + f.store[currentKey] = map[string]string{"priv": priv, "ver": strconv.Itoa(newVer)} return newVer, nil } @@ -122,9 +122,8 @@ func newTestStore(fake *fakeHashClient) *valkeyStore { } func TestValkeyStore_Set(t *testing.T) { - pubKey := bytes.Repeat([]byte{0xAB}, 65) privKey := bytes.Repeat([]byte{0xCD}, 32) - pair := RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey} + pair := RoomKeyPair{PrivateKey: privKey} tests := []struct { name string @@ -161,7 +160,6 @@ func TestValkeyStore_Set(t *testing.T) { // Verify the hash was written under the correct Valkey key. stored := tt.fake.store[roomkey(tt.roomID)] require.NotNil(t, stored, "hash should exist in fake store") - assert.NotEmpty(t, stored["pub"], "pub field should be set") assert.NotEmpty(t, stored["priv"], "priv field should be set") assert.Equal(t, "0", stored["ver"], "ver field should be 0") }) @@ -169,9 +167,8 @@ func TestValkeyStore_Set(t *testing.T) { } func TestValkeyStore_SetWithVersion(t *testing.T) { - pubKey := bytes.Repeat([]byte{0xAB}, 65) privKey := bytes.Repeat([]byte{0xCD}, 32) - pair := RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey} + pair := RoomKeyPair{PrivateKey: privKey} t.Run("writes pair at supplied version", func(t *testing.T) { fake := &fakeHashClient{} @@ -180,7 +177,6 @@ func TestValkeyStore_SetWithVersion(t *testing.T) { stored := fake.store[roomkey("r1")] require.NotNil(t, stored) assert.Equal(t, "5", stored["ver"]) - assert.NotEmpty(t, stored["pub"]) assert.NotEmpty(t, stored["priv"]) }) @@ -205,7 +201,6 @@ func TestValkeyStore_SetWithVersion(t *testing.T) { } func TestValkeyStore_Get(t *testing.T) { - pubKey := bytes.Repeat([]byte{0xAB}, 65) privKey := bytes.Repeat([]byte{0xCD}, 32) tests := []struct { @@ -222,11 +217,11 @@ func TestValkeyStore_Get(t *testing.T) { fake: func() *fakeHashClient { f := &fakeHashClient{} store := newTestStore(f) - _, _ = store.Set(context.Background(), "room-1", RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey}) + _, _ = store.Set(context.Background(), "room-1", RoomKeyPair{PrivateKey: privKey}) return f }(), roomID: "room-1", - wantPair: &RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey}, + wantPair: &RoomKeyPair{PrivateKey: privKey}, wantVer: 0, }, { @@ -242,10 +237,10 @@ func TestValkeyStore_Get(t *testing.T) { errContains: "get room key", }, { - name: "corrupted pub base64 — returns error", + name: "corrupted priv base64 — returns error", fake: &fakeHashClient{ store: map[string]map[string]string{ - roomkey("room-1"): {"pub": "!!!notbase64!!!", "priv": "AQID", "ver": "0"}, + roomkey("room-1"): {"priv": "!!!notbase64!!!", "ver": "0"}, }, }, roomID: "room-1", @@ -253,26 +248,26 @@ func TestValkeyStore_Get(t *testing.T) { errContains: "get room key", }, { - name: "corrupted priv base64 — returns error", + name: "non-numeric version — returns error containing parse version", fake: &fakeHashClient{ store: map[string]map[string]string{ - roomkey("room-1"): {"pub": "AQID", "priv": "!!!notbase64!!!", "ver": "0"}, + roomkey("room-1"): {"priv": "zc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc0=", "ver": "not-a-number"}, }, }, roomID: "room-1", wantErr: true, - errContains: "get room key", + errContains: "parse version", }, { - name: "non-numeric version — returns error containing parse version", + name: "old row with pub field — pub is ignored, priv is decoded", fake: &fakeHashClient{ store: map[string]map[string]string{ - roomkey("room-1"): {"pub": "AQID", "priv": "AQID", "ver": "not-a-number"}, + roomkey("room-1"): {"pub": "AQID", "priv": "zc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc0=", "ver": "0"}, }, }, - roomID: "room-1", - wantErr: true, - errContains: "parse version", + roomID: "room-1", + wantPair: &RoomKeyPair{PrivateKey: bytes.Repeat([]byte{0xCD}, 32)}, + wantVer: 0, }, } @@ -293,16 +288,13 @@ func TestValkeyStore_Get(t *testing.T) { } require.NotNil(t, got) assert.Equal(t, tt.wantVer, got.Version) - assert.Equal(t, tt.wantPair.PublicKey, got.KeyPair.PublicKey) assert.Equal(t, tt.wantPair.PrivateKey, got.KeyPair.PrivateKey) }) } } func TestValkeyStore_GetByVersion(t *testing.T) { - pubKey := bytes.Repeat([]byte{0xAB}, 65) privKey := bytes.Repeat([]byte{0xCD}, 32) - pubKey2 := bytes.Repeat([]byte{0x11}, 65) privKey2 := bytes.Repeat([]byte{0x22}, 32) tests := []struct { @@ -319,32 +311,32 @@ func TestValkeyStore_GetByVersion(t *testing.T) { fake: func() *fakeHashClient { f := &fakeHashClient{} s := newTestStore(f) - _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey}) + _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PrivateKey: privKey}) return f }(), roomID: "room-1", version: 0, - wantPair: &RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey}, + wantPair: &RoomKeyPair{PrivateKey: privKey}, }, { name: "matches previous key after rotation", fake: func() *fakeHashClient { f := &fakeHashClient{} s := newTestStore(f) - _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey}) - _, _ = s.Rotate(context.Background(), "room-1", RoomKeyPair{PublicKey: pubKey2, PrivateKey: privKey2}) + _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PrivateKey: privKey}) + _, _ = s.Rotate(context.Background(), "room-1", RoomKeyPair{PrivateKey: privKey2}) return f }(), roomID: "room-1", version: 0, - wantPair: &RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey}, + wantPair: &RoomKeyPair{PrivateKey: privKey}, }, { name: "no match — returns nil, nil", fake: func() *fakeHashClient { f := &fakeHashClient{} s := newTestStore(f) - _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey}) + _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PrivateKey: privKey}) return f }(), roomID: "room-1", @@ -370,7 +362,7 @@ func TestValkeyStore_GetByVersion(t *testing.T) { f := &fakeHashClient{} s := newTestStore(f) // Set a current key with a different version so the code falls through to check previous. - _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey}) + _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PrivateKey: privKey}) f.hgetallErr = errors.New("connection reset") f.hgetallErrOnCall = 2 // error only on the second hgetall (previous key lookup) return f @@ -384,8 +376,8 @@ func TestValkeyStore_GetByVersion(t *testing.T) { name: "corrupted previous key base64 — returns error", fake: &fakeHashClient{ store: map[string]map[string]string{ - roomkey("room-1"): {"pub": "AQID", "priv": "AQID", "ver": "0"}, - roomprevkey("room-1"): {"pub": "!!!bad!!!", "priv": "AQID", "ver": "99"}, + roomkey("room-1"): {"priv": "zc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc0=", "ver": "0"}, + roomprevkey("room-1"): {"priv": "!!!bad!!!", "ver": "99"}, }, }, roomID: "room-1", @@ -397,7 +389,7 @@ func TestValkeyStore_GetByVersion(t *testing.T) { name: "corrupted current key base64 — returns error when version matches current", fake: &fakeHashClient{ store: map[string]map[string]string{ - roomkey("room-1"): {"pub": "!!!bad!!!", "priv": "AQID", "ver": "0"}, + roomkey("room-1"): {"priv": "!!!bad!!!", "ver": "0"}, }, }, roomID: "room-1", @@ -405,6 +397,17 @@ func TestValkeyStore_GetByVersion(t *testing.T) { wantErr: true, errContains: "get room key by version", }, + { + name: "old row with pub field — pub is ignored, priv is decoded", + fake: &fakeHashClient{ + store: map[string]map[string]string{ + roomkey("room-1"): {"pub": "AQID", "priv": "zc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc0=", "ver": "0"}, + }, + }, + roomID: "room-1", + version: 0, + wantPair: &RoomKeyPair{PrivateKey: bytes.Repeat([]byte{0xCD}, 32)}, + }, } for _, tt := range tests { @@ -423,16 +426,13 @@ func TestValkeyStore_GetByVersion(t *testing.T) { return } require.NotNil(t, got) - assert.Equal(t, tt.wantPair.PublicKey, got.PublicKey) assert.Equal(t, tt.wantPair.PrivateKey, got.PrivateKey) }) } } func TestValkeyStore_Rotate(t *testing.T) { - pubKey := bytes.Repeat([]byte{0xAB}, 65) privKey := bytes.Repeat([]byte{0xCD}, 32) - newPubKey := bytes.Repeat([]byte{0x11}, 65) newPrivKey := bytes.Repeat([]byte{0x22}, 32) tests := []struct { @@ -450,7 +450,7 @@ func TestValkeyStore_Rotate(t *testing.T) { fake: &fakeHashClient{}, roomID: "room-1", setupFn: func(s *valkeyStore) { - _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey}) + _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PrivateKey: privKey}) }, wantVer: 1, }, @@ -467,9 +467,9 @@ func TestValkeyStore_Rotate(t *testing.T) { fake: &fakeHashClient{}, roomID: "room-1", setupFn: func(s *valkeyStore) { - _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey}) + _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PrivateKey: privKey}) // First rotation creates a previous key. - _, _ = s.Rotate(context.Background(), "room-1", RoomKeyPair{PublicKey: newPubKey, PrivateKey: newPrivKey}) + _, _ = s.Rotate(context.Background(), "room-1", RoomKeyPair{PrivateKey: newPrivKey}) }, wantVer: 2, }, @@ -480,7 +480,7 @@ func TestValkeyStore_Rotate(t *testing.T) { setupFn: func(s *valkeyStore) { // Temporarily clear the error so Set works. s.client.(*fakeHashClient).rotatePipelineErr = nil - _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey}) + _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PrivateKey: privKey}) s.client.(*fakeHashClient).rotatePipelineErr = errors.New("pipeline broken") }, wantErr: true, @@ -494,7 +494,7 @@ func TestValkeyStore_Rotate(t *testing.T) { if tt.setupFn != nil { tt.setupFn(store) } - ver, err := store.Rotate(context.Background(), tt.roomID, RoomKeyPair{PublicKey: newPubKey, PrivateKey: newPrivKey}) + ver, err := store.Rotate(context.Background(), tt.roomID, RoomKeyPair{PrivateKey: newPrivKey}) if tt.wantErr { require.Error(t, err) assert.Contains(t, err.Error(), tt.errContains) @@ -511,14 +511,12 @@ func TestValkeyStore_Rotate(t *testing.T) { require.NoError(t, err) require.NotNil(t, got) assert.Equal(t, tt.wantVer, got.Version) - assert.Equal(t, newPubKey, got.KeyPair.PublicKey) assert.Equal(t, newPrivKey, got.KeyPair.PrivateKey) }) } } func TestValkeyStore_Delete(t *testing.T) { - pubKey := bytes.Repeat([]byte{0xAB}, 65) privKey := bytes.Repeat([]byte{0xCD}, 32) tests := []struct { @@ -533,9 +531,8 @@ func TestValkeyStore_Delete(t *testing.T) { fake: func() *fakeHashClient { f := &fakeHashClient{} s := newTestStore(f) - _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey}) + _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PrivateKey: privKey}) _, _ = s.Rotate(context.Background(), "room-1", RoomKeyPair{ - PublicKey: bytes.Repeat([]byte{0x11}, 65), PrivateKey: bytes.Repeat([]byte{0x22}, 32), }) return f @@ -576,9 +573,7 @@ func TestValkeyStore_Delete(t *testing.T) { } func TestValkeyStore_GetMany(t *testing.T) { - pubKey := bytes.Repeat([]byte{0xAB}, 65) privKey := bytes.Repeat([]byte{0xCD}, 32) - pubKey2 := bytes.Repeat([]byte{0x11}, 65) privKey2 := bytes.Repeat([]byte{0x22}, 32) tests := []struct { @@ -603,8 +598,8 @@ func TestValkeyStore_GetMany(t *testing.T) { fake: func() *fakeHashClient { f := &fakeHashClient{} s := newTestStore(f) - _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey}) - _, _ = s.Set(context.Background(), "room-2", RoomKeyPair{PublicKey: pubKey2, PrivateKey: privKey2}) + _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PrivateKey: privKey}) + _, _ = s.Set(context.Background(), "room-2", RoomKeyPair{PrivateKey: privKey2}) return f }(), roomIDs: []string{"room-1", "room-2"}, @@ -624,8 +619,8 @@ func TestValkeyStore_GetMany(t *testing.T) { fake: func() *fakeHashClient { f := &fakeHashClient{} s := newTestStore(f) - _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey}) - _, _ = s.Set(context.Background(), "room-2", RoomKeyPair{PublicKey: pubKey2, PrivateKey: privKey2}) + _, _ = s.Set(context.Background(), "room-1", RoomKeyPair{PrivateKey: privKey}) + _, _ = s.Set(context.Background(), "room-2", RoomKeyPair{PrivateKey: privKey2}) return f }(), roomIDs: []string{"room-1", "room-absent", "room-2"}, @@ -637,8 +632,9 @@ func TestValkeyStore_GetMany(t *testing.T) { name: "decode error — error containing room ID", fake: &fakeHashClient{ store: map[string]map[string]string{ - roomkey("room-1"): {"pub": "AQID", "priv": "AQID", "ver": "0"}, - roomkey("room-2"): {"pub": "!!!notbase64!!!", "priv": "AQID", "ver": "0"}, + // room-1 has a valid 32-byte secret so decoding succeeds. + roomkey("room-1"): {"priv": "zc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc0=", "ver": "0"}, + roomkey("room-2"): {"priv": "!!!notbase64!!!", "ver": "0"}, }, }, roomIDs: []string{"room-1", "room-2"}, @@ -650,7 +646,7 @@ func TestValkeyStore_GetMany(t *testing.T) { name: "version parse error — error containing room ID", fake: &fakeHashClient{ store: map[string]map[string]string{ - roomkey("room-1"): {"pub": "AQID", "priv": "AQID", "ver": "not-a-number"}, + roomkey("room-1"): {"priv": "zc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc0=", "ver": "not-a-number"}, }, }, roomIDs: []string{"room-1"}, diff --git a/room-service/handler.go b/room-service/handler.go index c904d6e37..50771761d 100644 --- a/room-service/handler.go +++ b/room-service/handler.go @@ -355,7 +355,7 @@ func (h *Handler) publishCreateRoom(ctx context.Context, req *model.CreateRoomRe if err != nil { return nil, fmt.Errorf("generate room key: %w", err) } - if _, err := h.keyStore.Set(ctx, req.RoomID, pair); err != nil { + if _, err := h.keyStore.Set(ctx, req.RoomID, *pair); err != nil { roomkeymetrics.ValkeyErrors.Add(ctx, 1, metric.WithAttributes(attribute.String("op", "Set"))) return nil, fmt.Errorf("store room key: %w", err) } @@ -1208,7 +1208,7 @@ func (h *Handler) handleEnsureRoomKey(ctx context.Context, data []byte) ([]byte, if err != nil { return nil, fmt.Errorf("ensure room key: generate key pair: %w", err) } - ver, err := h.keyStore.Set(ctx, req.RoomID, newPair) + ver, err := h.keyStore.Set(ctx, req.RoomID, *newPair) if err != nil { return nil, fmt.Errorf("ensure room key: set: %w", err) } diff --git a/room-service/handler_test.go b/room-service/handler_test.go index 9df2b9281..870926101 100644 --- a/room-service/handler_test.go +++ b/room-service/handler_test.go @@ -1684,8 +1684,8 @@ func TestHandler_handleRoomsInfoBatch(t *testing.T) { }, setupKeys: func(k *MockRoomKeyStore) { k.EXPECT().GetMany(gomock.Any(), []string{"r1", "r2", "r3"}).Return(map[string]*roomkeystore.VersionedKeyPair{ - "r1": {Version: 1, KeyPair: roomkeystore.RoomKeyPair{PrivateKey: privBytes, PublicKey: []byte("pub1")}}, - "r3": {Version: 5, KeyPair: roomkeystore.RoomKeyPair{PrivateKey: privBytes, PublicKey: []byte("pub3")}}, + "r1": {Version: 1, KeyPair: roomkeystore.RoomKeyPair{PrivateKey: privBytes}}, + "r3": {Version: 5, KeyPair: roomkeystore.RoomKeyPair{PrivateKey: privBytes}}, }, nil) }, assertResp: func(t *testing.T, resp model.RoomsInfoBatchResponse) { @@ -3002,7 +3002,6 @@ func TestHandler_CreateRoom_WritesKeyBeforePublish(t *testing.T) { keyStore.EXPECT().Set(gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, roomID string, pair roomkeystore.RoomKeyPair) (int, error) { assert.NotEmpty(t, roomID) - assert.Len(t, pair.PublicKey, 65) assert.Len(t, pair.PrivateKey, 32) keyStored = true return 0, nil @@ -3096,7 +3095,6 @@ func TestHandler_EnsureRoomKey_KeyExists(t *testing.T) { existing := &roomkeystore.VersionedKeyPair{ Version: 7, KeyPair: roomkeystore.RoomKeyPair{ - PublicKey: bytes.Repeat([]byte{0xAB}, 65), PrivateKey: bytes.Repeat([]byte{0xCD}, 32), }, } @@ -3144,8 +3142,7 @@ func TestHandler_EnsureRoomKey_KeyNotFound_SetsNew(t *testing.T) { assert.Equal(t, "room-new", result.RoomID) assert.Equal(t, 0, result.Version) - assert.Len(t, capturedPair.PublicKey, 65, "P-256 public key must be 65 bytes (uncompressed) — stored in Valkey") - assert.Len(t, capturedPair.PrivateKey, 32, "P-256 private key must be 32 bytes — stored in Valkey") + assert.Len(t, capturedPair.PrivateKey, 32, "room secret must be 32 bytes — stored in Valkey") assert.NotContains(t, string(resp), "publicKey", "response must not include public key bytes") assert.NotContains(t, string(resp), "privateKey", "response must not include private key bytes") } diff --git a/room-service/integration_test.go b/room-service/integration_test.go index 83335aa3d..732ec4c10 100644 --- a/room-service/integration_test.go +++ b/room-service/integration_test.go @@ -1145,12 +1145,11 @@ func TestRoomsInfoBatchRPC(t *testing.T) { require.NoError(t, store.CreateRoom(ctx, &rooms[i])) } - pubKey := bytes.Repeat([]byte{0xAB}, 65) privKey1 := bytes.Repeat([]byte{0x01}, 32) privKey2 := bytes.Repeat([]byte{0x02}, 32) - _, err := keyStore.Set(ctx, "r1", roomkeystore.RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey1}) + _, err := keyStore.Set(ctx, "r1", roomkeystore.RoomKeyPair{PrivateKey: privKey1}) require.NoError(t, err) - _, err = keyStore.Set(ctx, "r2", roomkeystore.RoomKeyPair{PublicKey: pubKey, PrivateKey: privKey2}) + _, err = keyStore.Set(ctx, "r2", roomkeystore.RoomKeyPair{PrivateKey: privKey2}) require.NoError(t, err) otelNC, err := otelnats.Connect(natsURL) @@ -1251,7 +1250,6 @@ func TestIntegration_CreateRoom_PersistsKeyInValkey(t *testing.T) { pair, err := keyStore.Get(ctx, reply.RoomID) require.NoError(t, err) require.NotNil(t, pair, "room key must be stored in Valkey immediately after create") - assert.NotEmpty(t, pair.KeyPair.PublicKey, "public key must be non-empty") assert.NotEmpty(t, pair.KeyPair.PrivateKey, "private key must be non-empty") assert.Equal(t, 0, pair.Version, "freshly created room key must have version 0") } @@ -1535,3 +1533,4 @@ func TestMongoStore_ListReadReceipts_Integration(t *testing.T) { require.NoError(t, err) require.Empty(t, rows) } + diff --git a/room-service/store_mongo.go b/room-service/store_mongo.go index e699c328e..eb536f655 100644 --- a/room-service/store_mongo.go +++ b/room-service/store_mongo.go @@ -83,11 +83,8 @@ func (s *MongoStore) EnsureIndexes(ctx context.Context) error { }); err != nil { return fmt.Errorf("ensure subscriptions (roomId,lastSeenAt) index: %w", err) } - // Lookup index for FindDMSubscription, which filters on (u.account, name, - // roomType) without roomId. The existing (roomId, u.account) unique index - // can't satisfy this query as an index prefix because roomId isn't in the - // filter — without this index, every DM/BotDM lookup falls back to a - // collection scan on subscriptions. + // Lookup index for FindDMSubscription (filters on u.account+name). + // Without this index, FindDMSubscription falls back to a collection scan. if _, err := s.subscriptions.Indexes().CreateOne(ctx, mongo.IndexModel{ Keys: bson.D{{Key: "u.account", Value: 1}, {Key: "name", Value: 1}}, }); err != nil { diff --git a/room-worker/handler.go b/room-worker/handler.go index 4d7e57d3e..afd56f06d 100644 --- a/room-worker/handler.go +++ b/room-worker/handler.go @@ -316,21 +316,24 @@ func (h *Handler) rotateAndFanOut(ctx context.Context, roomID string, currentPai if currentPair != nil { predictedVersion = currentPair.Version + 1 } - versioned := &roomkeystore.VersionedKeyPair{Version: predictedVersion, KeyPair: newPair} + versioned := &roomkeystore.VersionedKeyPair{Version: predictedVersion, KeyPair: *newPair} h.fanOutRoomKeyToSurvivors(ctx, roomID, versioned, survivors) if currentPair == nil { - if _, err := h.keyStore.Set(ctx, roomID, newPair); err != nil { + if _, err := h.keyStore.Set(ctx, roomID, *newPair); err != nil { roomkeymetrics.ValkeyErrors.Add(ctx, 1, metric.WithAttributes(attribute.String("op", "Set"))) return fmt.Errorf("store room key (no prior): %w", err) } roomkeymetrics.KeyGenerated.Add(ctx, 1) return nil } - if _, err := h.keyStore.Rotate(ctx, roomID, newPair); err != nil { + if _, err := h.keyStore.Rotate(ctx, roomID, *newPair); err != nil { if errors.Is(err, roomkeystore.ErrNoCurrentKey) { - if _, setErr := h.keyStore.Set(ctx, roomID, newPair); setErr != nil { - roomkeymetrics.ValkeyErrors.Add(ctx, 1, metric.WithAttributes(attribute.String("op", "Set"))) + // Fan-out already committed survivors to predictedVersion; persist at + // the same version so broadcast-worker reads under the same key clients + // hold. Using Set here would stamp v0 and create a version mismatch. + if setErr := h.keyStore.SetWithVersion(ctx, roomID, *newPair, predictedVersion); setErr != nil { + roomkeymetrics.ValkeyErrors.Add(ctx, 1, metric.WithAttributes(attribute.String("op", "SetWithVersion"))) return fmt.Errorf("store room key (fallback): %w", setErr) } roomkeymetrics.KeyGenerated.Add(ctx, 1) diff --git a/room-worker/handler_test.go b/room-worker/handler_test.go index 3dd9d67d0..8676cc248 100644 --- a/room-worker/handler_test.go +++ b/room-worker/handler_test.go @@ -3294,7 +3294,6 @@ func TestBuildAndFanOutRoomKey_SendsToAllMembersIncludingRemoteSite(t *testing.T keyPair := &roomkeystore.VersionedKeyPair{ Version: 3, KeyPair: roomkeystore.RoomKeyPair{ - PublicKey: []byte("pub"), PrivateKey: []byte("priv"), }, } @@ -3368,7 +3367,6 @@ func TestProcessAddMembers_FansOutKeyToNewAccountsOnly(t *testing.T) { pair := &roomkeystore.VersionedKeyPair{ Version: 1, KeyPair: roomkeystore.RoomKeyPair{ - PublicKey: []byte("pubkey"), PrivateKey: []byte("privkey"), }, } @@ -3522,7 +3520,7 @@ func TestFanOutRoomKeyToSurvivors_SendsToAllSurvivorsIncludingRemoteSite(t *test keySender := roomkeysender.NewSender(pub) pair := &roomkeystore.VersionedKeyPair{Version: 5, KeyPair: roomkeystore.RoomKeyPair{ - PublicKey: bytes.Repeat([]byte{0x04}, 65), PrivateKey: bytes.Repeat([]byte{0x03}, 32), + PrivateKey: bytes.Repeat([]byte{0x03}, 32), }} survivors := []model.Subscription{ {User: model.SubscriptionUser{Account: "alice"}, RoomID: "r1", SiteID: "site-a"}, @@ -4310,3 +4308,52 @@ func TestProcessCreateRoom_InvalidRequestID_ReturnsPermanent(t *testing.T) { assert.ErrorIs(t, err, errPermanent) assert.Contains(t, err.Error(), "invalid X-Request-ID") } + +// TestHandler_RotateAndFanOut_ErrNoCurrentKey_UsesPredictedVersion pins the +// contract that when Rotate returns ErrNoCurrentKey (Valkey lost the key between +// Get and Rotate), the fallback calls SetWithVersion at predictedVersion +// (currentPair.Version+1) rather than Set (which would stamp v0), preventing the +// version mismatch that would render the next encrypted message undecryptable. +func TestHandler_RotateAndFanOut_ErrNoCurrentKey_UsesPredictedVersion(t *testing.T) { + ctrl := gomock.NewController(t) + mockKeys := NewMockRoomKeyStore(ctrl) + + // currentPair simulates the key the handler fetched before calling rotateAndFanOut. + currentPair := &roomkeystore.VersionedKeyPair{ + Version: 4, + KeyPair: roomkeystore.RoomKeyPair{ + PrivateKey: bytes.Repeat([]byte{0xAA}, 32), + }, + } + // predictedVersion = currentPair.Version + 1 = 5 + + // Step 1: Rotate fails with ErrNoCurrentKey — Valkey lost the current key + // between Get (which returned currentPair) and Rotate. + gomock.InOrder( + mockKeys.EXPECT(). + Rotate(gomock.Any(), "test-room", gomock.Any()). + Return(0, roomkeystore.ErrNoCurrentKey), + // Step 2: fallback must write at predictedVersion=5, NOT at v0 via Set. + // If the bug were present (Set called instead), gomock would raise + // "unexpected call to Set" because Set is not expected here. + mockKeys.EXPECT(). + SetWithVersion(gomock.Any(), "test-room", gomock.Any(), 5). + Return(nil), + ) + + h := &Handler{ + store: NewMockSubscriptionStore(ctrl), + siteID: "site-a", + keyStore: mockKeys, + keySender: testKeySender, + publish: func(_ context.Context, _ string, _ []byte, _ string) error { + return nil + }, + } + + // Call rotateAndFanOut directly — it is unexported but lives in package main, + // so the test (same package) can call it without test infrastructure. + // Pass an empty survivors slice: no fan-out side effects needed for this test. + err := h.rotateAndFanOut(context.Background(), "test-room", currentPair, nil) + require.NoError(t, err) +} diff --git a/room-worker/integration_test.go b/room-worker/integration_test.go index 63177f9cc..a13039d39 100644 --- a/room-worker/integration_test.go +++ b/room-worker/integration_test.go @@ -3,6 +3,7 @@ package main import ( + "bytes" "context" "encoding/json" "slices" @@ -1210,8 +1211,7 @@ func TestIntegration_CreateRoom_FansOutRoomKeyEvent(t *testing.T) { keyStore := setupValkey(t) const roomID = "test-fan-out-room" seedPair := roomkeystore.RoomKeyPair{ - PublicKey: []byte("public-key-bytes"), - PrivateKey: []byte("private-key-bytes"), + PrivateKey: bytes.Repeat([]byte{0xAA}, 32), } _, err := keyStore.Set(ctx, roomID, seedPair) require.NoError(t, err) @@ -1272,7 +1272,7 @@ func TestIntegration_CreateRoom_FansOutRoomKeyEvent(t *testing.T) { var evt model.RoomKeyEvent require.NoError(t, json.Unmarshal(m.data, &evt)) assert.Equal(t, roomID, evt.RoomID, "RoomKeyEvent must carry the correct roomID") - assert.Empty(t, evt.PublicKey, "PublicKey must be omitted from the client wire payload") + assert.NotEmpty(t, evt.PrivateKey, "PrivateKey must be populated in the client wire payload") assert.NotEmpty(t, evt.PrivateKey, "PrivateKey must be populated") } assert.ElementsMatch(t, diff --git a/room-worker/mock_publisher_test.go b/room-worker/mock_publisher_test.go index e95d13b9b..6f3c1caa6 100644 --- a/room-worker/mock_publisher_test.go +++ b/room-worker/mock_publisher_test.go @@ -46,7 +46,6 @@ func (stubRoomKeyStore) Get(_ context.Context, _ string) (*roomkeystore.Versione return &roomkeystore.VersionedKeyPair{ Version: 0, KeyPair: roomkeystore.RoomKeyPair{ - PublicKey: bytes.Repeat([]byte{0x04}, 65), PrivateKey: bytes.Repeat([]byte{0x05}, 32), }, }, nil @@ -56,6 +55,10 @@ func (stubRoomKeyStore) Set(_ context.Context, _ string, _ roomkeystore.RoomKeyP return 0, nil } +func (stubRoomKeyStore) SetWithVersion(_ context.Context, _ string, _ roomkeystore.RoomKeyPair, _ int) error { + return nil +} + func (stubRoomKeyStore) Rotate(_ context.Context, _ string, _ roomkeystore.RoomKeyPair) (int, error) { return 1, nil } diff --git a/room-worker/mock_store_test.go b/room-worker/mock_store_test.go index 9f0454d3e..a0adce1b2 100644 --- a/room-worker/mock_store_test.go +++ b/room-worker/mock_store_test.go @@ -447,3 +447,17 @@ func (mr *MockRoomKeyStoreMockRecorder) Set(ctx, roomID, pair any) *gomock.Call mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockRoomKeyStore)(nil).Set), ctx, roomID, pair) } + +// SetWithVersion mocks base method. +func (m *MockRoomKeyStore) SetWithVersion(ctx context.Context, roomID string, pair roomkeystore.RoomKeyPair, version int) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetWithVersion", ctx, roomID, pair, version) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetWithVersion indicates an expected call of SetWithVersion. +func (mr *MockRoomKeyStoreMockRecorder) SetWithVersion(ctx, roomID, pair, version any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetWithVersion", reflect.TypeOf((*MockRoomKeyStore)(nil).SetWithVersion), ctx, roomID, pair, version) +} diff --git a/room-worker/store.go b/room-worker/store.go index a8733b218..34cc1fdf7 100644 --- a/room-worker/store.go +++ b/room-worker/store.go @@ -99,8 +99,12 @@ type SubscriptionStore interface { // Key store used by room-worker: reads for fan-out, writes for rotation. type RoomKeyStore interface { Get(ctx context.Context, roomID string) (*roomkeystore.VersionedKeyPair, error) - // Set writes a fresh keypair at version 0 — fallback when Rotate finds no current key. + // Set writes a fresh keypair at version 0 — used when seeding a brand-new room. Set(ctx context.Context, roomID string, pair roomkeystore.RoomKeyPair) (int, error) + // SetWithVersion writes pair at an explicit version. Used by the rotate + // fallback when Rotate finds no current key but fan-out already committed + // to predictedVersion = currentPair.Version + 1. + SetWithVersion(ctx context.Context, roomID string, pair roomkeystore.RoomKeyPair, version int) error // Rotate atomically increments version and writes newPair as current. Rotate(ctx context.Context, roomID string, newPair roomkeystore.RoomKeyPair) (int, error) } diff --git a/tools/loadgen/preset.go b/tools/loadgen/preset.go index bf34f3e59..48d609a40 100644 --- a/tools/loadgen/preset.go +++ b/tools/loadgen/preset.go @@ -1,7 +1,6 @@ package main import ( - "crypto/ecdh" "fmt" "io" "math/rand" @@ -144,28 +143,15 @@ func BuildFixtures(p *Preset, seed int64, siteID string) Fixtures { return Fixtures{Users: users, Rooms: rooms, Subscriptions: subs, RoomKeys: roomKeys} } -// deterministicRoomKeyPair builds a P-256 keypair from bytes drawn from r. -// Reads 32-byte scalars directly via NewPrivateKey instead of GenerateKey -// because the stdlib's GenerateKey calls randutil.MaybeReadByte, which draws -// 0 or 1 byte from its internal non-deterministic source and breaks -// reproducibility across calls with the same math/rand seed. The retry loop -// covers the astronomically rare case of a zero or out-of-range scalar; it -// consumes only deterministic bytes from r so the output stays a function of -// the seed alone. +// deterministicRoomKeyPair generates a 32-byte room secret from bytes drawn +// from r. The secret is used directly as an AES-256-GCM key by roomcrypto; no +// key derivation step is needed. The name retains "KeyPair" for call-site compatibility. func deterministicRoomKeyPair(r io.Reader) roomkeystore.RoomKeyPair { buf := make([]byte, 32) - for { - if _, err := io.ReadFull(r, buf); err != nil { - panic(fmt.Errorf("read deterministic key bytes: %w", err)) - } - priv, err := ecdh.P256().NewPrivateKey(buf) - if err == nil { - return roomkeystore.RoomKeyPair{ - PublicKey: priv.PublicKey().Bytes(), - PrivateKey: priv.Bytes(), - } - } + if _, err := io.ReadFull(r, buf); err != nil { + panic(fmt.Errorf("read deterministic key bytes: %w", err)) } + return roomkeystore.RoomKeyPair{PrivateKey: buf} } func pickMembers(r *rand.Rand, p *Preset, roomIdx, totalRooms int, room *model.Room, users []model.User) []model.User { diff --git a/tools/loadgen/preset_test.go b/tools/loadgen/preset_test.go index 17e7a726b..1a4c9eb24 100644 --- a/tools/loadgen/preset_test.go +++ b/tools/loadgen/preset_test.go @@ -69,8 +69,6 @@ func TestBuildFixtures_RoomKeysOnePerRoom(t *testing.T) { for _, r := range f.Rooms { pair, ok := f.RoomKeys[r.ID] require.True(t, ok, "missing key for room %s", r.ID) - // P-256 uncompressed public key is 65 bytes; scalar is 32 bytes. - assert.Len(t, pair.PublicKey, 65) assert.Len(t, pair.PrivateKey, 32) } } diff --git a/tools/loadgen/seed_test.go b/tools/loadgen/seed_test.go index 7fde41118..964b6d8cf 100644 --- a/tools/loadgen/seed_test.go +++ b/tools/loadgen/seed_test.go @@ -41,23 +41,23 @@ func (f *fakeRoomKeyStore) Delete(_ context.Context, roomID string) error { func TestSeedRoomKeys_WritesOnePerRoom(t *testing.T) { ks := newFakeRoomKeyStore() keys := map[string]roomkeystore.RoomKeyPair{ - "room-a": {PublicKey: []byte("pubA"), PrivateKey: []byte("privA")}, - "room-b": {PublicKey: []byte("pubB"), PrivateKey: []byte("privB")}, - "room-c": {PublicKey: []byte("pubC"), PrivateKey: []byte("privC")}, + "room-a": {PrivateKey: []byte("privA")}, + "room-b": {PrivateKey: []byte("privB")}, + "room-c": {PrivateKey: []byte("privC")}, } require.NoError(t, SeedRoomKeys(context.Background(), ks, keys)) assert.Len(t, ks.sets, 3) - assert.Equal(t, []byte("pubA"), ks.sets["room-a"].PublicKey) + assert.Equal(t, []byte("privA"), ks.sets["room-a"].PrivateKey) assert.Equal(t, []byte("privB"), ks.sets["room-b"].PrivateKey) - assert.Equal(t, []byte("pubC"), ks.sets["room-c"].PublicKey) + assert.Equal(t, []byte("privC"), ks.sets["room-c"].PrivateKey) } func TestSeedRoomKeys_KeystoreError(t *testing.T) { ks := newFakeRoomKeyStore() ks.setErr = errors.New("boom") - keys := map[string]roomkeystore.RoomKeyPair{"room-a": {PublicKey: []byte("p"), PrivateKey: []byte("k")}} + keys := map[string]roomkeystore.RoomKeyPair{"room-a": {PrivateKey: []byte("k")}} err := SeedRoomKeys(context.Background(), ks, keys) require.Error(t, err)