Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
fdd327f
docs(roomcrypto): add ECDH performance analysis + Stage-2 design spec
claude May 20, 2026
3eff9f7
docs(roomcrypto): expand spec to include chat-frontend decoder in scope
claude May 20, 2026
e2e5a5c
plan(roomcrypto): implementation plan for HKDF-only encryption migration
claude May 20, 2026
fc4a639
feat(roomcrypto): add HKDF-only Encoder type alongside legacy Encode
claude May 20, 2026
79fc822
fix(roomcrypto): wrap aeadFor errors, document eviction policy, valid…
claude May 20, 2026
706383f
test(roomcrypto): assert Encoder caches AEAD per (roomID, version)
claude May 20, 2026
4068964
test(roomcrypto): assert Encoder evicts lowest version when full
claude May 20, 2026
28475cb
test(roomcrypto): cover Encoder error paths (nonce reader, key length)
claude May 20, 2026
c113626
test(roomcrypto): round-trip Encoder output via inline HKDF/GCM
claude May 20, 2026
ccdcad0
bench(roomcrypto): add BenchmarkEncoder_Encode for the new hot path
claude May 20, 2026
5a24336
feat(broadcast-worker): use HKDF-only roomcrypto.Encoder
claude May 20, 2026
950f423
fix(broadcast-worker): rename ROOMCRYPTO_CACHE_SIZE to ROOM_CRYPTO_CA…
claude May 20, 2026
0c63e9e
refactor(roomcrypto): remove legacy ECIES Encode, drop crypto/ecdh dep
claude May 20, 2026
6025812
fix(roomkeystore,roomcrypto): rewrite keygen_test to use direct curve…
claude May 20, 2026
c71b480
test(roomcrypto): integration test uses HKDF-only scheme and rewrites…
claude May 20, 2026
97d9ad6
feat(subject): add RoomsKeysBootstrap subject builders
claude May 20, 2026
8f75953
feat(model): add RoomsKeysResponse and RoomsKeysEntry types
claude May 20, 2026
b6f666c
feat(room-service): add chat.user.{account}.request.rooms.keys RPC
claude May 20, 2026
54ca2ac
feat(chat-frontend): scaffold lib/roomcrypto with b64decode helper
claude May 20, 2026
5a6ced7
feat(chat-frontend): add deriveAesKey via Web Crypto HKDF-SHA-256
claude May 20, 2026
dd7b6fc
feat(chat-frontend): add decryptRoomMessage via Web Crypto AES-GCM
claude May 20, 2026
d486fdb
test(chat-frontend): cross-language fixture for Go encode → TS decode
claude May 20, 2026
51cbbdd
feat(chat-frontend/api): subject builders for room-key events and boo…
claude May 20, 2026
00532a9
feat(chat-frontend/api): add RoomKeyEvent + RoomKeys{Entry,Response} …
claude May 20, 2026
4f7b3f5
feat(chat-frontend/api): subscribeToRoomKeyEvents
claude May 20, 2026
2d244c2
feat(chat-frontend/api): fetchRoomKeysBootstrap RPC wrapper
claude May 20, 2026
946cbc6
feat(chat-frontend): RoomKeysContext reducer with idempotent key insert
claude May 20, 2026
67bb9c5
feat(chat-frontend): RoomKeysProvider with bootstrap + live subscription
claude May 20, 2026
1bd8e37
feat(chat-frontend): mount RoomKeysProvider in app tree
claude May 20, 2026
e3c185b
feat(chat-frontend): decrypt new-message + edit events before dispatch
claude May 20, 2026
f452468
test(chat-frontend): reducer handles decrypted event identically to p…
claude May 20, 2026
759b860
docs(client-api): document HKDF-only encryption and keys-bootstrap RPC
claude May 20, 2026
aae57e4
fix: integration tests + tighten natsListRoomKeys subject validation
claude May 20, 2026
d8e474c
fix(roomkeysender): update TS integration client to HKDF-only scheme
claude May 20, 2026
63bc00a
fix: address PR #207 review feedback
claude May 20, 2026
748b462
simplify: shrink subscriptions index + guard redundant AES cache evic…
claude May 20, 2026
96f3dc4
chore: tighten code to reduce PR review surface
claude May 20, 2026
3ca1807
fix(roomcrypto): add nonce + ciphertext length guards in TS decryptor
claude May 20, 2026
82bfbfd
refactor(broadcast-worker): make roomcrypto.Encoder an internal detail
claude May 20, 2026
8918725
refactor(roomkeystore): drop room public key, store only the 32-byte …
claude May 20, 2026
b87f84f
fix: address PR #207 review — version mismatch, nil keystore, decode …
claude May 20, 2026
ee67744
fix(room-worker): use 32-byte seed key in CreateRoom integration test
claude May 20, 2026
595b6ce
fix: address 3 more bugs from extended review pass
claude May 20, 2026
113608e
test: integration coverage for ListSubscriptionsByAccount; unit test …
claude May 20, 2026
5e57c28
refactor: remove HKDF — use room secret directly as AES-256-GCM key
claude May 21, 2026
5c97bd2
Merge remote-tracking branch 'origin/main' into claude/ecdh-performan…
claude May 21, 2026
654122c
refactor: remove natsListRoomKeys bootstrap RPC; drop legacy Ephemera…
claude May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions broadcast-worker/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
1 change: 0 additions & 1 deletion broadcast-worker/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
30 changes: 5 additions & 25 deletions broadcast-worker/testhelpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
}
Expand Down
67 changes: 67 additions & 0 deletions chat-frontend/scripts/gen-crypto-fixtures.go
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 8 additions & 5 deletions chat-frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -35,11 +36,13 @@ function AppContent() {
}

return (
<RoomEventsProvider>
<ThreadEventsProvider>
<MainApp />
</ThreadEventsProvider>
</RoomEventsProvider>
<RoomKeysProvider>
<RoomEventsProvider>
<ThreadEventsProvider>
<MainApp />
</ThreadEventsProvider>
</RoomEventsProvider>
</RoomKeysProvider>
)
}

Expand Down
7 changes: 7 additions & 0 deletions chat-frontend/src/api/_transport/subjects.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import {
userRoomEvent,
userRoomKey,
roomEvent,
memberAdd,
memberRemove,
Expand Down Expand Up @@ -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')
})
})

4 changes: 4 additions & 0 deletions chat-frontend/src/api/_transport/subjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
}
Expand Down
2 changes: 2 additions & 0 deletions chat-frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -72,4 +73,5 @@ export type {
ChannelRef,
HistoryConfig,
HistoryMode,
RoomKeyEvent,
} from './types'
15 changes: 15 additions & 0 deletions chat-frontend/src/api/subscribeToRoomKeyEvents/index.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
10 changes: 10 additions & 0 deletions chat-frontend/src/api/subscribeToRoomKeyEvents/index.ts
Original file line number Diff line number Diff line change
@@ -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<Nats, 'subscribe' | 'user'>,
callback: SubscriptionCallback,
): NatsSubscription {
return subscribe(userRoomKey(user.account), callback)
}
Comment on lines +5 to +10
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Use the standard API operation signature and pass opts to subscribe.

Please update this op to match the required operationName(nats, args, opts?) signature and forward opts to the NATS primitive for consistency across src/api/*/index.ts.

As per coding guidelines: “API operations must have … operationName(nats, args, opts?)” and “Always pass opts parameter through to NATS primitives … use three-argument shape.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@chat-frontend/src/api/subscribeToRoomKeyEvents/index.ts` around lines 5 - 10,
Change subscribeToRoomKeyEvents to follow the standard operation signature
operationName(nats, args, opts?) by accepting (nats: Nats, args: { user:
Nats['user'] }, opts?: SubscribeOptions) and forward opts to the NATS primitive;
specifically, update the function signature for subscribeToRoomKeyEvents,
extract subscribe and user from the nats arg (or use args.user), call
subscribe(userRoomKey(user.account), callback, opts) so the third-argument shape
is preserved, and keep the return type as NatsSubscription.

13 changes: 13 additions & 0 deletions chat-frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<S = unknown, A = unknown> {
requestId: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<header>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 }>,
Expand Down Expand Up @@ -147,6 +149,7 @@ export function RoomEventsProvider({ children }: { children: ReactNode }) {
stateRef,
threadReplyHandlerRef,
threadMessageMutationHandlerRef,
decrypt,
)

const loadHistory = useCallback(
Expand Down
26 changes: 26 additions & 0 deletions chat-frontend/src/context/RoomEventsContext/reducer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading