Skip to content
Merged
Show file tree
Hide file tree
Changes from 33 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
9 changes: 5 additions & 4 deletions broadcast-worker/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@ 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}
func NewHandler(store Store, userStore userstore.UserStore, pub Publisher, keyStore RoomKeyProvider, encrypt bool, encoder *roomcrypto.Encoder) *Handler {
return &Handler{store: store, userStore: userStore, pub: pub, keyStore: keyStore, encrypt: encrypt, encoder: encoder}
}

// HandleMessage processes a single MESSAGES_CANONICAL message payload.
Expand Down Expand Up @@ -207,7 +208,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 @@ -251,7 +252,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
52 changes: 26 additions & 26 deletions broadcast-worker/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ func TestHandleMessage_DispatchesByEvent(t *testing.T) {
us.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"sender"}).Return(nil, nil)
}

h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
err = h.HandleMessage(context.Background(), data)

if tc.wantErr {
Expand Down Expand Up @@ -222,7 +222,7 @@ func TestHandler_HandleMessage_ChannelRoom(t *testing.T) {
// Sender lookup
us.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"sender"}).Return([]model.User{senderUser}, nil)

h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
err := h.HandleMessage(context.Background(), makeMessageEvent("room-1", tc.content, msgTime))
require.NoError(t, err)

Expand Down Expand Up @@ -321,7 +321,7 @@ func TestHandler_HandleMessage_DMRoom(t *testing.T) {
us.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"alice"}).Return([]model.User{testUsers[0]}, nil)

keyStore := NewMockRoomKeyProvider(ctrl)
h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
err := h.HandleMessage(context.Background(), data)
require.NoError(t, err)

Expand Down Expand Up @@ -361,7 +361,7 @@ func TestHandler_HandleMessage_Errors(t *testing.T) {
us := NewMockUserStore(ctrl)
pub := &mockPublisher{}
keyStore := NewMockRoomKeyProvider(ctrl)
h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())

err := h.HandleMessage(context.Background(), []byte("not json"))
require.Error(t, err)
Expand All @@ -377,7 +377,7 @@ func TestHandler_HandleMessage_Errors(t *testing.T) {
store.EXPECT().UpdateRoomLastMessage(gomock.Any(), "room-1", "msg-1", msgTime, false).Return(errors.New("not found"))

keyStore := NewMockRoomKeyProvider(ctrl)
h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
err := h.HandleMessage(context.Background(), makeMessageEvent("room-1", "hello", msgTime))
require.Error(t, err)
assert.Empty(t, pub.records)
Expand All @@ -392,7 +392,7 @@ func TestHandler_HandleMessage_Errors(t *testing.T) {
store.EXPECT().UpdateRoomLastMessage(gomock.Any(), "room-1", "msg-1", msgTime, false).Return(errors.New("db error"))

keyStore := NewMockRoomKeyProvider(ctrl)
h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
err := h.HandleMessage(context.Background(), makeMessageEvent("room-1", "hello", msgTime))
require.Error(t, err)
assert.Empty(t, pub.records)
Expand All @@ -410,7 +410,7 @@ func TestHandler_HandleMessage_Errors(t *testing.T) {
store.EXPECT().SetSubscriptionMentions(gomock.Any(), "room-1", gomock.Any()).Return(errors.New("db error"))

keyStore := NewMockRoomKeyProvider(ctrl)
h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
err := h.HandleMessage(context.Background(), makeMessageEvent("room-1", "hey @alice", msgTime))
require.Error(t, err)
assert.Contains(t, err.Error(), "set subscription mentions")
Expand All @@ -432,7 +432,7 @@ func TestHandler_HandleMessage_Errors(t *testing.T) {
us.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"sender"}).Return(nil, nil) // sender lookup

keyStore := NewMockRoomKeyProvider(ctrl)
h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
err := h.HandleMessage(context.Background(), makeMessageEvent("room-1", "hello", msgTime))
require.NoError(t, err)
assert.Empty(t, pub.records)
Expand All @@ -450,7 +450,7 @@ func TestHandler_HandleMessage_Errors(t *testing.T) {
store.EXPECT().ListSubscriptions(gomock.Any(), "dm-1").Return(nil, errors.New("db error"))

keyStore := NewMockRoomKeyProvider(ctrl)
h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
evt := model.MessageEvent{
Event: model.EventCreated,
SiteID: "site-a",
Expand Down Expand Up @@ -483,7 +483,7 @@ func TestHandler_HandleMessage_Errors(t *testing.T) {
us.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"sender"}).Return([]model.User{senderUser}, nil) // mention lookup
us.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"sender"}).Return([]model.User{senderUser}, nil) // sender lookup

h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
err := h.HandleMessage(context.Background(), makeMessageEvent("room-1", "hey @sender", msgTime))
require.NoError(t, err)

Expand All @@ -509,7 +509,7 @@ func TestHandler_HandleMessage_Errors(t *testing.T) {
store.EXPECT().GetRoomMeta(gomock.Any(), "room-1").Return(metaOf(testChannelRoom), nil)
us.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"sender"}).Return(nil, errors.New("db error")) // sender lookup

h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
err := h.HandleMessage(context.Background(), makeMessageEvent("room-1", "hello", msgTime))
require.NoError(t, err)

Expand Down Expand Up @@ -551,7 +551,7 @@ func TestHandler_HandleMessage_DMRoom_PublishError(t *testing.T) {
us.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"alice"}).Return([]model.User{testUsers[0]}, nil) // sender lookup

keyStore := NewMockRoomKeyProvider(ctrl)
h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
evt := model.MessageEvent{
Event: model.EventCreated,
SiteID: "site-a",
Expand Down Expand Up @@ -583,7 +583,7 @@ func TestHandler_HandleMessage_ChannelRoom_Encryption(t *testing.T) {
store.EXPECT().GetRoomMeta(gomock.Any(), "room-1").Return(metaOf(testChannelRoom), nil)
us.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"sender"}).Return(nil, nil)

h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
err := h.HandleMessage(context.Background(), makeMessageEvent("room-1", "hello", msgTime))
require.Error(t, err)
assert.ErrorIs(t, err, errNoCurrentKey)
Expand All @@ -603,7 +603,7 @@ func TestHandler_HandleMessage_ChannelRoom_Encryption(t *testing.T) {
store.EXPECT().GetRoomMeta(gomock.Any(), "room-1").Return(metaOf(testChannelRoom), nil)
us.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"sender"}).Return(nil, nil)

h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
err := h.HandleMessage(context.Background(), makeMessageEvent("room-1", "hello", msgTime))
require.Error(t, err)
assert.Contains(t, err.Error(), "get room key")
Expand All @@ -625,7 +625,7 @@ func TestHandler_HandleMessage_ChannelRoom_Encryption(t *testing.T) {
store.EXPECT().GetRoomMeta(gomock.Any(), "room-1").Return(metaOf(testChannelRoom), nil)
us.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"sender"}).Return([]model.User{{ID: "u-sender", Account: "sender", EngName: "Sender Lin", ChineseName: "寄件者", SiteID: "site-a"}}, nil)

h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
err := h.HandleMessage(context.Background(), makeMessageEvent("room-1", "hello", msgTime))
require.NoError(t, err)

Expand All @@ -650,7 +650,7 @@ 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.Empty(t, env.EphemeralPublicKey, "HKDF-only scheme does not use ephemeral public key")
assert.NotEmpty(t, env.Nonce)
assert.NotEmpty(t, env.Ciphertext)

Expand Down Expand Up @@ -706,7 +706,7 @@ func TestHandler_FetchAndUpdateRoom_Missing(t *testing.T) {
Return(fmt.Errorf("update room last message ghost-room: %w", mongo.ErrNoDocuments))

keyStore := NewMockRoomKeyProvider(ctrl)
h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())

err := h.HandleMessage(context.Background(), makeMessageEvent("ghost-room", "hello", msgTime))
require.Error(t, err)
Expand Down Expand Up @@ -744,7 +744,7 @@ func TestHandleUpdated_ChannelRoomScopedPublish(t *testing.T) {
data, err := json.Marshal(&evt)
require.NoError(t, err)

h := NewHandler(store, us, pub, keyStore, false)
h := NewHandler(store, us, pub, keyStore, false, roomcrypto.NewEncoder())
require.NoError(t, h.HandleMessage(context.Background(), data))

require.Len(t, pub.records, 1, "channel: single room-scoped publish")
Expand Down Expand Up @@ -792,7 +792,7 @@ func TestHandleUpdated_EncryptedChannel_EncryptsContent(t *testing.T) {
data, err := json.Marshal(&evt)
require.NoError(t, err)

h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
require.NoError(t, h.HandleMessage(context.Background(), data))

require.Len(t, pub.records, 1, "channel: single room-scoped publish")
Expand Down Expand Up @@ -828,7 +828,7 @@ func TestHandleUpdated_MissingEditedAt_ReturnsError(t *testing.T) {
data, err := json.Marshal(&evt)
require.NoError(t, err)

h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
err = h.HandleMessage(context.Background(), data)
require.Error(t, err)
assert.Contains(t, err.Error(), "missing EditedAt")
Expand Down Expand Up @@ -863,7 +863,7 @@ func TestHandleDeleted_ChannelRoomScopedPublish(t *testing.T) {
data, err := json.Marshal(&evt)
require.NoError(t, err)

h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
require.NoError(t, h.HandleMessage(context.Background(), data))

require.Len(t, pub.records, 1, "channel: single room-scoped publish")
Expand Down Expand Up @@ -898,7 +898,7 @@ func TestHandleDeleted_MissingUpdatedAt_ReturnsError(t *testing.T) {
data, err := json.Marshal(&evt)
require.NoError(t, err)

h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
err = h.HandleMessage(context.Background(), data)
require.Error(t, err)
assert.Contains(t, err.Error(), "missing UpdatedAt")
Expand Down Expand Up @@ -940,7 +940,7 @@ func TestHandleUpdated_DMRoom_FansOutToBothMembers(t *testing.T) {
data, err := json.Marshal(&evt)
require.NoError(t, err)

h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
require.NoError(t, h.HandleMessage(context.Background(), data))

require.Len(t, pub.records, 2, "per-user fan-out: one publish per DM member")
Expand Down Expand Up @@ -996,7 +996,7 @@ func TestHandleDeleted_DMRoom_FansOutToBothMembers(t *testing.T) {
data, err := json.Marshal(&evt)
require.NoError(t, err)

h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
require.NoError(t, h.HandleMessage(context.Background(), data))

require.Len(t, pub.records, 2, "per-user fan-out: one publish per DM member")
Expand Down Expand Up @@ -1053,7 +1053,7 @@ func TestHandleUpdated_BotDMRoom_SkipsBotAccount(t *testing.T) {
data, err := json.Marshal(&evt)
require.NoError(t, err)

h := NewHandler(store, us, pub, keyStore, true)
h := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())
require.NoError(t, h.HandleMessage(context.Background(), data))

require.Len(t, pub.records, 1, "botDM: only the human recipient gets the live event")
Expand Down Expand Up @@ -1109,7 +1109,7 @@ func TestHandler_HandleMessage_ChannelEncryptionDisabled(t *testing.T) {
us.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"sender"}).Return([]model.User{senderUser}, nil)

// nil keyStore — handler must NOT dereference it when encrypt=false
h := NewHandler(store, us, pub, nil, false)
h := NewHandler(store, us, pub, nil, false, roomcrypto.NewEncoder())
err := h.HandleMessage(context.Background(), makeMessageEvent("room-1", tc.content, msgTime))
require.NoError(t, err)

Expand Down
13 changes: 7 additions & 6 deletions broadcast-worker/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo"

"github.com/hmchangw/chat/pkg/model"
"github.com/hmchangw/chat/pkg/roomcrypto"
"github.com/hmchangw/chat/pkg/roomkeystore"
"github.com/hmchangw/chat/pkg/subject"
"github.com/hmchangw/chat/pkg/testutil"
Expand Down Expand Up @@ -82,7 +83,7 @@ func TestBroadcastWorker_ChannelRoom_Integration(t *testing.T) {
pub := &recordingPublisher{}
key := testRoomKey(t)
keyStore := &fakeRoomKeyProvider{pair: key}
handler := NewHandler(store, us, pub, keyStore, true)
handler := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())

msgTime := time.Now().UTC().Truncate(time.Millisecond)
evt := model.MessageEvent{
Expand Down Expand Up @@ -128,7 +129,7 @@ func TestBroadcastWorker_ChannelRoom_MentionAll_Integration(t *testing.T) {
pub := &recordingPublisher{}
key := testRoomKey(t)
keyStore := &fakeRoomKeyProvider{pair: key}
handler := NewHandler(store, us, pub, keyStore, true)
handler := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())

msgTime := time.Now().UTC().Truncate(time.Millisecond)
evt := model.MessageEvent{
Expand Down Expand Up @@ -168,7 +169,7 @@ func TestBroadcastWorker_ChannelRoom_IndividualMention_Integration(t *testing.T)
pub := &recordingPublisher{}
key := testRoomKey(t)
keyStore := &fakeRoomKeyProvider{pair: key}
handler := NewHandler(store, us, pub, keyStore, true)
handler := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())

msgTime := time.Now().UTC().Truncate(time.Millisecond)
evt := model.MessageEvent{
Expand Down Expand Up @@ -218,7 +219,7 @@ func TestBroadcastWorker_DMRoom_Integration(t *testing.T) {
us := userstore.NewMongoStore(db.Collection("users"))
pub := &recordingPublisher{}
keyStore := &fakeRoomKeyProvider{pair: nil}
handler := NewHandler(store, us, pub, keyStore, true)
handler := NewHandler(store, us, pub, keyStore, true, roomcrypto.NewEncoder())

msgTime := time.Now().UTC().Truncate(time.Millisecond)
evt := model.MessageEvent{
Expand Down Expand Up @@ -280,7 +281,7 @@ func TestBroadcastWorker_ChannelRoom_EncryptionDisabled_Integration(t *testing.T
pub := &recordingPublisher{}

// nil keyStore — encryption is disabled, handler must not consult it
handler := NewHandler(store, us, pub, nil, false)
handler := NewHandler(store, us, pub, nil, false, roomcrypto.NewEncoder())

msgTime := time.Now().UTC().Truncate(time.Millisecond)
evt := model.MessageEvent{
Expand Down Expand Up @@ -331,7 +332,7 @@ func TestBroadcastWorker_PersistsLastMessage_Integration(t *testing.T) {
require.NoError(t, err)

pub := &recordingPublisher{}
h := NewHandler(cached, userstore.NewMongoStore(db.Collection("users")), pub, &fakeRoomKeyProvider{}, false)
h := NewHandler(cached, userstore.NewMongoStore(db.Collection("users")), pub, &fakeRoomKeyProvider{}, false, roomcrypto.NewEncoder())

msgTime := time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)
evt := model.MessageEvent{
Expand Down
6 changes: 5 additions & 1 deletion broadcast-worker/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/hmchangw/chat/pkg/mongoutil"
"github.com/hmchangw/chat/pkg/natsutil"
"github.com/hmchangw/chat/pkg/otelutil"
"github.com/hmchangw/chat/pkg/roomcrypto"
"github.com/hmchangw/chat/pkg/roomkeystore"
"github.com/hmchangw/chat/pkg/shutdown"
"github.com/hmchangw/chat/pkg/stream"
Expand All @@ -43,6 +44,7 @@ type config struct {
ValkeyAddr string `env:"VALKEY_ADDR"`
ValkeyPassword string `env:"VALKEY_PASSWORD" envDefault:""`
ValkeyKeyGracePeriod time.Duration `env:"VALKEY_KEY_GRACE_PERIOD" envDefault:"24h"`
RoomCryptoCacheSize int `env:"ROOM_CRYPTO_CACHE_SIZE" envDefault:"4096"`
Consumer stream.ConsumerSettings `envPrefix:"CONSUMER_"`
Bootstrap bootstrapConfig `envPrefix:"BOOTSTRAP_"`
Encryption encryptionConfig `envPrefix:"ENCRYPTION_"`
Expand Down Expand Up @@ -130,8 +132,10 @@ func main() {
os.Exit(1)
}

encoder := roomcrypto.NewEncoder(roomcrypto.WithMaxCacheEntries(cfg.RoomCryptoCacheSize))
slog.Info("roomcrypto-encoder-cache enabled", "size", cfg.RoomCryptoCacheSize)
publisher := &natsPublisher{nc: nc}
handler := NewHandler(cachedStore, us, publisher, keyStore, cfg.Encryption.Enabled)
handler := NewHandler(cachedStore, us, publisher, keyStore, cfg.Encryption.Enabled, encoder)

iter, err := cons.Messages(jetstream.PullMaxMessages(2 * cfg.MaxWorkers))
if err != nil {
Expand Down
18 changes: 5 additions & 13 deletions broadcast-worker/testhelpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import (
"github.com/hmchangw/chat/pkg/roomkeystore"
)

// hkdfInfo is the HKDF info string used by the Encoder (HKDF-only scheme).
const hkdfInfo = "room-message-encryption-v2"

func testRoomKey(t *testing.T) *roomkeystore.VersionedKeyPair {
t.Helper()
priv, err := ecdh.P256().GenerateKey(rand.Reader)
Expand All @@ -33,20 +36,9 @@ func testRoomKey(t *testing.T) *roomkeystore.VersionedKeyPair {
}

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)
}
// New HKDF-only scheme: derive AES key directly from the room private key.
aesKey := make([]byte, 32)
hkdfReader := hkdf.New(sha256.New, sharedSecret, nil, []byte("room-message-encryption"))
hkdfReader := hkdf.New(sha256.New, roomPrivateKey, nil, []byte(hkdfInfo))
if _, err := io.ReadFull(hkdfReader, aesKey); err != nil {
return "", fmt.Errorf("hkdf: %w", err)
}
Expand Down
Loading
Loading