-
Notifications
You must be signed in to change notification settings - Fork 0
feat(roomcrypto): direct AES-256-GCM room encryption; add client decoder #207
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 40 commits
fdd327f
3eff9f7
e2e5a5c
fc4a639
79fc822
706383f
4068964
28475cb
c113626
ccdcad0
5a24336
950f423
0c63e9e
6025812
c71b480
97d9ad6
8f75953
b6f666c
54ca2ac
5a6ced7
dd7b6fc
d486fdb
51cbbdd
00532a9
4f7b3f5
2d244c2
946cbc6
67bb9c5
1bd8e37
e3c185b
f452468
759b860
aae57e4
d8e474c
63bc00a
748b462
96f3dc4
3ca1807
82bfbfd
8918725
b87f84f
ee67744
595b6ce
113608e
5e57c28
5c97bd2
654122c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 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 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| 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' }] }) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| 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<Nats, 'request' | 'user'>, | ||
| ): Promise<RoomKeysResponse> { | ||
| return request<RoomKeysResponse>(roomsKeysBootstrap(user.account), {}) | ||
| } | ||
| 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() | ||
| }) | ||
| }) |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Please update this op to match the required As per coding guidelines: “API operations must have … 🤖 Prompt for AI Agents |
||
There was a problem hiding this comment.
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
Align this API op to the required
(nats, args, opts?)contract and 3-arg request shape.This operation currently bypasses the standard API signature and does not pass
optsthrough torequest(...). Please switch to the repo’s canonical API-op shape and forwardoptsto the NATS call (even whenundefined) for consistency with the operation tests/contracts.As per coding guidelines: “API operations must have …
operationName(nats, args, opts?)” and “Always passoptsparameter through to NATS primitives … use three-argument shape.”🤖 Prompt for AI Agents