@paramms/chat-widget 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ asUserId, asConversationId, asMessageId,
4
+ type ServerFrame, type Message,
5
+ } from '../protocol/index.js'
6
+ import { ChatStore } from '../store.js'
7
+
8
+ const me = asUserId('alice')
9
+ const cid = asConversationId('c1')
10
+
11
+ function srvMsg(seq: number, from: string, text: string): Message {
12
+ return { id: asMessageId(`s${seq}`), conversationId: cid, seq, senderId: asUserId(from), senderRole: from === 'alice' ? 'guest' : 'agent', content: { kind: 'text', text }, ts: seq }
13
+ }
14
+
15
+ describe('ChatStore', () => {
16
+ it('reconciles an optimistic send on ack (rekey, seq, status)', () => {
17
+ const s = new ChatStore(me)
18
+ s.apply({ type: 'opened', conversation: { id: cid, tenantId: 't' as never, profileId: 'p' as never, guestId: me, participants: [me], state: 'open', lastSeq: 0, createdAt: 0, updatedAt: 0 } })
19
+ s.addOptimistic('cm1', { kind: 'text', text: 'hi' })
20
+ expect(s.messages()[0]!.status).toBe('pending')
21
+ s.apply({ type: 'ack', clientMsgId: 'cm1', messageId: asMessageId('s1'), seq: 1, ts: 10 })
22
+ const m = s.messages()[0]!
23
+ expect(m.id).toBe('s1')
24
+ expect(m.seq).toBe(1)
25
+ expect(m.status).toBe('sent')
26
+ expect(s.messages()).toHaveLength(1) // not duplicated
27
+ })
28
+
29
+ it('dedups the same message arriving live and via sync', () => {
30
+ const s = new ChatStore(me)
31
+ s.apply({ type: 'message', message: srvMsg(2, 'bob', 'yo') })
32
+ s.apply({ type: 'sync', conversationId: cid, messages: [srvMsg(1, 'bob', 'first'), srvMsg(2, 'bob', 'yo')] })
33
+ expect(s.messages().map(m => m.seq)).toEqual([1, 2]) // seq-ordered, no dup of seq 2
34
+ })
35
+
36
+ it('progresses own-message status delivered → read', () => {
37
+ const s = new ChatStore(me)
38
+ s.addOptimistic('cm1', { kind: 'text', text: 'hi' })
39
+ s.apply({ type: 'ack', clientMsgId: 'cm1', messageId: asMessageId('s1'), seq: 1, ts: 1 })
40
+ s.apply({ type: 'delivered', conversationId: cid, seq: 1, to: me })
41
+ expect(s.messages()[0]!.status).toBe('delivered')
42
+ s.apply({ type: 'read', conversationId: cid, seq: 1, by: asUserId('bob') })
43
+ expect(s.messages()[0]!.status).toBe('read')
44
+ expect(s.lastReadByOthers).toBe(1)
45
+ })
46
+
47
+ it('tracks typing from others only', () => {
48
+ const s = new ChatStore(me)
49
+ s.apply({ type: 'typing', conversationId: cid, userId: asUserId('bob'), isTyping: true })
50
+ s.apply({ type: 'typing', conversationId: cid, userId: me, isTyping: true }) // self ignored
51
+ expect([...s.typing]).toEqual(['bob'])
52
+ s.apply({ type: 'typing', conversationId: cid, userId: asUserId('bob'), isTyping: false })
53
+ expect(s.typing.size).toBe(0)
54
+ })
55
+
56
+ it('toggles reactions and applies edit/delete', () => {
57
+ const s = new ChatStore(me)
58
+ s.apply({ type: 'message', message: srvMsg(1, 'bob', 'hi') })
59
+ s.apply({ type: 'reaction', conversationId: cid, messageId: asMessageId('s1'), emoji: '👍', by: me, removed: false })
60
+ expect(s.messages()[0]!.reactions).toEqual({ '👍': [me] })
61
+ s.apply({ type: 'reaction', conversationId: cid, messageId: asMessageId('s1'), emoji: '👍', by: me, removed: true })
62
+ expect(s.messages()[0]!.reactions).toEqual({})
63
+ s.apply({ type: 'edited', conversationId: cid, messageId: asMessageId('s1'), content: { kind: 'text', text: 'edited' }, editedAt: 5 })
64
+ expect(s.messages()[0]!.content).toEqual({ kind: 'text', text: 'edited' })
65
+ s.apply({ type: 'deleted', conversationId: cid, messageId: asMessageId('s1'), ts: 6 })
66
+ expect(s.messages()[0]!.deletedAt).toBe(6)
67
+ })
68
+
69
+ it('filters visible actions by conversation state', () => {
70
+ const s = new ChatStore(me)
71
+ s.apply({ type: 'manifest', conversationId: cid, version: 1, actions: [
72
+ { id: asMessageId('a1') as never, label: 'Always', audience: 'guest', surface: 'toolbar' },
73
+ { id: asMessageId('a2') as never, label: 'Closed only', audience: 'guest', surface: 'toolbar', availableInStates: ['closed'] },
74
+ ] })
75
+ s.apply({ type: 'opened', conversation: { id: cid, tenantId: 't' as never, profileId: 'p' as never, guestId: me, participants: [me], state: 'open', lastSeq: 0, createdAt: 0, updatedAt: 0 } })
76
+ expect(s.visibleActions().map(a => a.label)).toEqual(['Always'])
77
+ s.apply({ type: 'state', conversationId: cid, state: 'closed' })
78
+ expect(s.visibleActions().map(a => a.label)).toEqual(['Always', 'Closed only'])
79
+ })
80
+
81
+ it('reports highest seq for the sync cursor', () => {
82
+ const s = new ChatStore(me)
83
+ s.apply({ type: 'sync', conversationId: cid, messages: [srvMsg(3, 'bob', 'c'), srvMsg(7, 'bob', 'g')] })
84
+ expect(s.highestSeq()).toBe(7)
85
+ })
86
+ })
@@ -0,0 +1,204 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ generateKeyPair, exportPublicKey, encrypt, decrypt,
4
+ signPrekey, verifyPrekeySignature,
5
+ x3dhSend, x3dhReceive,
6
+ generateIdentityKeyPair,
7
+ } from '../crypto.js'
8
+ import { E2ESession, extractX3DHInit } from '../e2e.js'
9
+
10
+ // ── X3DH crypto primitives ────────────────────────────────────────────────────
11
+
12
+ describe('X3DH crypto primitives', () => {
13
+ it('sender and recipient derive the same shared key', async () => {
14
+ // Recipient has: identity keypair (ECDH + ECDSA), a signed prekey, one OPK
15
+ const recipientIKP = await generateIdentityKeyPair()
16
+ const recipientSPK = await generateKeyPair()
17
+ const recipientOPK = await generateKeyPair()
18
+
19
+ const senderIKP = await generateIdentityKeyPair()
20
+
21
+ const bundle = {
22
+ identityKey: recipientIKP.publicKeyB64,
23
+ signedPrekey: await exportPublicKey(recipientSPK.publicKey),
24
+ signedPrekeyId: 'spk-001',
25
+ signature: await signPrekey(recipientIKP.ecdsaKP.privateKey, recipientSPK.publicKey),
26
+ oneTimePrekey: await exportPublicKey(recipientOPK.publicKey),
27
+ }
28
+
29
+ // Sender does X3DH
30
+ const { sharedKey: senderKey, ephemeralPublicKey } = await x3dhSend(senderIKP.ecdhKP, bundle)
31
+
32
+ // Recipient does X3DH
33
+ const recipientKey = await x3dhReceive(
34
+ recipientIKP.ecdhKP, recipientSPK,
35
+ senderIKP.publicKeyB64, ephemeralPublicKey,
36
+ recipientOPK,
37
+ )
38
+
39
+ // Both keys encrypt/decrypt to the same plaintext
40
+ const { ct, iv } = await encrypt(senderKey, 'async hello from offline sender')
41
+ const plain = await decrypt(recipientKey, ct, iv)
42
+ expect(plain).toBe('async hello from offline sender')
43
+ })
44
+
45
+ it('works without a one-time prekey (OPK optional)', async () => {
46
+ const recipientIKP = await generateIdentityKeyPair()
47
+ const recipientSPK = await generateKeyPair()
48
+ const senderIKP = await generateIdentityKeyPair()
49
+
50
+ const bundle = {
51
+ identityKey: recipientIKP.publicKeyB64,
52
+ signedPrekey: await exportPublicKey(recipientSPK.publicKey),
53
+ signedPrekeyId: 'spk-002',
54
+ signature: await signPrekey(recipientIKP.ecdsaKP.privateKey, recipientSPK.publicKey),
55
+ // no oneTimePrekey
56
+ }
57
+
58
+ const { sharedKey: senderKey, ephemeralPublicKey } = await x3dhSend(senderIKP.ecdhKP, bundle)
59
+ const recipientKey = await x3dhReceive(
60
+ recipientIKP.ecdhKP, recipientSPK,
61
+ senderIKP.publicKeyB64, ephemeralPublicKey,
62
+ undefined,
63
+ )
64
+
65
+ const { ct, iv } = await encrypt(senderKey, 'no-opk message')
66
+ expect(await decrypt(recipientKey, ct, iv)).toBe('no-opk message')
67
+ })
68
+
69
+ it('different ephemeral keys produce different shared secrets (no key reuse)', async () => {
70
+ const recipientIKP = await generateIdentityKeyPair()
71
+ const recipientSPK = await generateKeyPair()
72
+ const senderIKP = await generateIdentityKeyPair()
73
+
74
+ const bundle = {
75
+ identityKey: recipientIKP.publicKeyB64,
76
+ signedPrekey: await exportPublicKey(recipientSPK.publicKey),
77
+ signedPrekeyId: 'spk-003',
78
+ signature: await signPrekey(recipientIKP.ecdsaKP.privateKey, recipientSPK.publicKey),
79
+ }
80
+
81
+ const r1 = await x3dhSend(senderIKP.ecdhKP, bundle)
82
+ const r2 = await x3dhSend(senderIKP.ecdhKP, bundle)
83
+ // Different ephemeral keys → different shared secrets
84
+ expect(r1.ephemeralPublicKey).not.toBe(r2.ephemeralPublicKey)
85
+ const { ct, iv } = await encrypt(r1.sharedKey, 'msg1')
86
+ await expect(decrypt(r2.sharedKey, ct, iv)).rejects.toBeTruthy()
87
+ })
88
+
89
+ it('wrong sender IK on recipient side fails decryption', async () => {
90
+ const recipientIKP = await generateIdentityKeyPair()
91
+ const recipientSPK = await generateKeyPair()
92
+ const senderIKP = await generateIdentityKeyPair()
93
+ const wrongIKP = await generateIdentityKeyPair()
94
+
95
+ const bundle = {
96
+ identityKey: recipientIKP.publicKeyB64,
97
+ signedPrekey: await exportPublicKey(recipientSPK.publicKey),
98
+ signedPrekeyId: 'spk-004',
99
+ signature: await signPrekey(recipientIKP.ecdsaKP.privateKey, recipientSPK.publicKey),
100
+ }
101
+
102
+ const { sharedKey: senderKey, ephemeralPublicKey } = await x3dhSend(senderIKP.ecdhKP, bundle)
103
+ // Recipient rederives with WRONG sender IK
104
+ const wrongKey = await x3dhReceive(
105
+ recipientIKP.ecdhKP, recipientSPK,
106
+ wrongIKP.publicKeyB64, // wrong
107
+ ephemeralPublicKey,
108
+ )
109
+ const { ct, iv } = await encrypt(senderKey, 'secret')
110
+ await expect(decrypt(wrongKey, ct, iv)).rejects.toBeTruthy()
111
+ })
112
+
113
+ it('signPrekey and verifyPrekeySignature are consistent', async () => {
114
+ const ikp = await generateIdentityKeyPair()
115
+ const spkKP = await generateKeyPair()
116
+ const sig = await signPrekey(ikp.ecdsaKP.privateKey, spkKP.publicKey)
117
+ const spkPub = await exportPublicKey(spkKP.publicKey)
118
+ expect(await verifyPrekeySignature(ikp.sigPublicKeyB64, spkPub, sig)).toBe(true)
119
+ })
120
+
121
+ it('rejects tampered signature', async () => {
122
+ const ikp = await generateIdentityKeyPair()
123
+ const spkKP = await generateKeyPair()
124
+ const sig = await signPrekey(ikp.ecdsaKP.privateKey, spkKP.publicKey)
125
+ const spkPub = await exportPublicKey(spkKP.publicKey)
126
+ const tampered = sig.slice(0, -4) + 'AAAA'
127
+ expect(await verifyPrekeySignature(ikp.sigPublicKeyB64, spkPub, tampered)).toBe(false)
128
+ })
129
+ })
130
+
131
+ // ── E2ESession X3DH integration ───────────────────────────────────────────────
132
+
133
+ describe('E2ESession — X3DH async mode', () => {
134
+ it('sender and recipient derive the same key and round-trip a message', async () => {
135
+ const sender = new E2ESession('test-sender')
136
+ const recipient = new E2ESession('test-recipient')
137
+
138
+ // Recipient initialises X3DH (uploads prekeys in prod)
139
+ const recipientUpload = await recipient.initX3DH()
140
+
141
+ // Sender fetches the bundle and does X3DH
142
+ const x3dhInit = await sender.x3dhSendTo({
143
+ identityKey: recipientUpload.identityKey,
144
+ signedPrekey: recipientUpload.signedPrekey,
145
+ signedPrekeyId: recipientUpload.signedPrekeyId,
146
+ signature: recipientUpload.signature,
147
+ oneTimePrekey: recipientUpload.oneTimePrekeys[0],
148
+ })
149
+ expect(sender.ready).toBe(true)
150
+ // x3dhSendTo now returns senderIK so it can be embedded in the init message
151
+ expect(x3dhInit.senderIK).toBeTruthy()
152
+
153
+ // Seal with X3DH init fields (senderIK embedded so recipient can rederive)
154
+ const sealed = await sender.sealText('hello offline world', x3dhInit)
155
+ expect(sealed.kind).toBe('text')
156
+ expect((sealed as { enc?: boolean }).enc).toBe(true)
157
+ expect((sealed as { text: string }).text).not.toContain('offline world')
158
+
159
+ const fields = extractX3DHInit(sealed)
160
+ expect(fields).not.toBeNull()
161
+ expect(fields!.x3dhEK).toBe(x3dhInit.ephemeralKey)
162
+ expect(fields!.x3dhIK).toBe(x3dhInit.senderIK)
163
+
164
+ // Recipient rederives using the sender's IK embedded in the init message
165
+ await recipient.x3dhReceiveFrom(fields!.x3dhIK, fields!.x3dhEK, fields!.x3dhSPK)
166
+ expect(recipient.ready).toBe(true)
167
+
168
+ const frame = {
169
+ type: 'message' as const,
170
+ message: { id: 'm1' as never, conversationId: 'c1' as never, seq: 1, senderId: 'u1' as never, senderRole: 'guest' as const, content: sealed, ts: 1 },
171
+ }
172
+ await recipient.openFrame(frame)
173
+ expect((frame.message.content as { text: string }).text).toBe('hello offline world')
174
+ })
175
+
176
+ it('live ECDH mode still works independently', async () => {
177
+ const guest = new E2ESession('guest-live')
178
+ const agent = new E2ESession('agent-live')
179
+
180
+ const guestPub = await guest.begin()
181
+ const agentPub = await agent.begin()
182
+
183
+ await guest.onPeerKey(agentPub)
184
+ await agent.onPeerKey(guestPub)
185
+
186
+ expect(guest.ready).toBe(true)
187
+ expect(agent.ready).toBe(true)
188
+
189
+ const sealed = await guest.sealText('live message')
190
+ const frame = {
191
+ type: 'message' as const,
192
+ message: { id: 'm1' as never, conversationId: 'c1' as never, seq: 1, senderId: 'u2' as never, senderRole: 'agent' as const, content: sealed, ts: 1 },
193
+ }
194
+ await agent.openFrame(frame)
195
+ expect((frame.message.content as { text: string }).text).toBe('live message')
196
+ })
197
+
198
+ it('extractX3DHInit returns null for non-E2E content', () => {
199
+ expect(extractX3DHInit({ kind: 'text', text: 'plain' })).toBeNull()
200
+ expect(extractX3DHInit({ kind: 'text', text: 'ct', enc: true, iv: 'iv' })).toBeNull()
201
+ expect(extractX3DHInit({ kind: 'card', title: 'x' })).toBeNull()
202
+ })
203
+ })
204
+
@@ -0,0 +1,133 @@
1
+ import {
2
+ encodeFrame, decodeFrame, isClientFrame,
3
+ type ClientFrame, type ServerFrame,
4
+ } from './protocol/index.js'
5
+
6
+ // Minimal socket surface so tests can inject a fake without a real WebSocket.
7
+ export interface SocketLike {
8
+ binaryType: string
9
+ send(data: Uint8Array): void
10
+ close(): void
11
+ onopen: (() => void) | null
12
+ onclose: (() => void) | null
13
+ onerror: (() => void) | null
14
+ onmessage: ((ev: { data: ArrayBuffer }) => void) | null
15
+ }
16
+ export type SocketFactory = (url: string) => SocketLike
17
+
18
+ export interface ConnectionOptions {
19
+ url: string
20
+ token: string
21
+ open: Extract<ClientFrame, { type: 'open' }> // what to (re)open on connect
22
+ onFrame: (frame: ServerFrame) => void
23
+ getCursor: () => number // highest seq seen (for sync on reconnect)
24
+ onStatusChange?: (status: 'connecting' | 'open' | 'reconnecting') => void
25
+ socketFactory?: SocketFactory
26
+ backoffBaseMs?: number
27
+ backoffMaxMs?: number
28
+ maxOutbox?: number
29
+ }
30
+
31
+ type State = 'idle' | 'connecting' | 'open' | 'closed'
32
+
33
+ export class ConnectionManager {
34
+ private socket: SocketLike | null = null
35
+ private state: State = 'idle'
36
+ private authed = false
37
+ private attempt = 0
38
+ private outbox: ClientFrame[] = []
39
+ private stopped = false
40
+ private timer: ReturnType<typeof setTimeout> | null = null
41
+
42
+ constructor(private readonly opts: ConnectionOptions) {}
43
+
44
+ connect(): void {
45
+ if (this.state === 'connecting' || this.state === 'open') return
46
+ this.stopped = false
47
+ this.state = 'connecting'
48
+ this.authed = false
49
+ this.opts.onStatusChange?.(this.attempt > 0 ? 'reconnecting' : 'connecting')
50
+ const make = this.opts.socketFactory ?? defaultFactory
51
+ const sock = make(this.opts.url)
52
+ sock.binaryType = 'arraybuffer'
53
+ this.socket = sock
54
+
55
+ sock.onopen = () => {
56
+ this.attempt = 0
57
+ // Auth first; the rest is sent once 'authed' arrives.
58
+ this.raw({ type: 'auth', token: this.opts.token })
59
+ }
60
+ sock.onmessage = (ev) => {
61
+ const frame = decodeFrame(new Uint8Array(ev.data))
62
+ if (!frame || isClientFrame(frame)) return // ignore non-server frames
63
+ this.handle(frame)
64
+ }
65
+ sock.onclose = () => this.onClosed()
66
+ sock.onerror = () => { try { sock.close() } catch { /* */ } }
67
+ }
68
+
69
+ /** Queue a frame; sent immediately if open, else flushed on (re)connect.
70
+ * The outbox is bounded so a prolonged outage can't grow memory without limit
71
+ * — oldest queued frames are dropped past the cap. */
72
+ send(frame: ClientFrame): void {
73
+ if (this.state === 'open' && this.authed) { this.raw(frame); return }
74
+ const cap = this.opts.maxOutbox ?? 1_000
75
+ if (this.outbox.length >= cap) {
76
+ // Evict oldest half when full to amortise the O(n) cost of overflow.
77
+ this.outbox = this.outbox.slice(this.outbox.length - (cap >> 1))
78
+ }
79
+ this.outbox.push(frame)
80
+ }
81
+
82
+ close(): void {
83
+ this.stopped = true
84
+ if (this.timer) clearTimeout(this.timer)
85
+ this.state = 'closed'
86
+ try { this.socket?.close() } catch { /* */ }
87
+ }
88
+
89
+ private handle(frame: ServerFrame): void {
90
+ if (frame.type === 'authed') {
91
+ this.state = 'open'
92
+ this.authed = true
93
+ this.opts.onStatusChange?.('open')
94
+ // Open/resolve the conversation. The catch-up `sync` is sent on 'opened'
95
+ // (below), i.e. only after the server has joined us to the room — sending
96
+ // it here would race the async open and be rejected as "not joined".
97
+ this.raw(this.opts.open)
98
+ this.flush()
99
+ }
100
+ // 'opened' confirms we're joined — now catch up from our cursor.
101
+ if (frame.type === 'opened') {
102
+ this.raw({ type: 'sync', conversationId: frame.conversation.id, sinceSeq: this.opts.getCursor() })
103
+ }
104
+ this.opts.onFrame(frame)
105
+ }
106
+
107
+ private flush(): void {
108
+ const pending = this.outbox
109
+ this.outbox = []
110
+ for (const f of pending) this.raw(f)
111
+ }
112
+
113
+ private raw(frame: ClientFrame): void {
114
+ try { this.socket?.send(encodeFrame(frame)) } catch { this.outbox.push(frame) }
115
+ }
116
+
117
+ private onClosed(): void {
118
+ this.authed = false
119
+ this.socket = null
120
+ if (this.stopped) { this.state = 'closed'; return }
121
+ this.state = 'idle'
122
+ // Exponential backoff with jitter; reconnect re-auths, re-opens, re-syncs.
123
+ const base = this.opts.backoffBaseMs ?? 500
124
+ const max = this.opts.backoffMaxMs ?? 15_000
125
+ const delay = Math.min(max, base * 2 ** this.attempt) * (0.5 + Math.random() * 0.5)
126
+ this.attempt++
127
+ this.timer = setTimeout(() => this.connect(), delay)
128
+ }
129
+ }
130
+
131
+ function defaultFactory(url: string): SocketLike {
132
+ return new WebSocket(url) as unknown as SocketLike
133
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * End-to-end encryption primitives (Web Crypto): ECDH P-256 for key agreement
3
+ * + AES-GCM for message content. The server only ever relays public keys and
4
+ * stores ciphertext — it cannot read messages.
5
+ *
6
+ * Scope/limitations (honest): this secures a *live 1:1* session — the guest and
7
+ * one agent exchange public keys while both are connected, then messages between
8
+ * them are encrypted. True asynchronous E2E (encrypting to an offline party)
9
+ * needs a prekey/X3DH scheme, which is out of scope here. When E2E is on, the
10
+ * AI assistant cannot read the room (by design).
11
+ */
12
+
13
+ const subtle = (): SubtleCrypto => globalThis.crypto.subtle
14
+
15
+ function b64encode(buf: ArrayBuffer | Uint8Array): string {
16
+ const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf)
17
+ let s = ''
18
+ for (const b of bytes) s += String.fromCharCode(b)
19
+ return btoa(s)
20
+ }
21
+ function b64decode(s: string): Uint8Array<ArrayBuffer> {
22
+ const bin = atob(s)
23
+ const buf = new ArrayBuffer(bin.length)
24
+ const out = new Uint8Array(buf)
25
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
26
+ return out
27
+ }
28
+
29
+ export interface KeyPair { publicKey: CryptoKey; privateKey: CryptoKey }
30
+
31
+ export async function generateKeyPair(): Promise<KeyPair> {
32
+ const kp = await subtle().generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits'])
33
+ return { publicKey: kp.publicKey, privateKey: kp.privateKey }
34
+ }
35
+
36
+ /** Export a public key to a compact base64 string (raw, 65 bytes for P-256). */
37
+ export async function exportPublicKey(key: CryptoKey): Promise<string> {
38
+ return b64encode(await subtle().exportKey('raw', key))
39
+ }
40
+
41
+ async function importPeerPublicKey(b64: string): Promise<CryptoKey> {
42
+ return subtle().importKey('raw', b64decode(b64), { name: 'ECDH', namedCurve: 'P-256' }, false, [])
43
+ }
44
+
45
+ /** Derive the shared AES-GCM key from our private key + the peer's public key. */
46
+ export async function deriveSharedKey(privateKey: CryptoKey, peerPublicKeyB64: string): Promise<CryptoKey> {
47
+ const peer = await importPeerPublicKey(peerPublicKeyB64)
48
+ return subtle().deriveKey(
49
+ { name: 'ECDH', public: peer },
50
+ privateKey,
51
+ { name: 'AES-GCM', length: 256 },
52
+ false,
53
+ ['encrypt', 'decrypt'],
54
+ )
55
+ }
56
+
57
+ export interface Ciphertext { ct: string; iv: string }
58
+
59
+ export async function encrypt(key: CryptoKey, plaintext: string): Promise<Ciphertext> {
60
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
61
+ const data = new TextEncoder().encode(plaintext)
62
+ const ct = await subtle().encrypt({ name: 'AES-GCM', iv }, key, data)
63
+ return { ct: b64encode(ct), iv: b64encode(iv) }
64
+ }
65
+
66
+ export async function decrypt(key: CryptoKey, ct: string, iv: string): Promise<string> {
67
+ const plain = await subtle().decrypt({ name: 'AES-GCM', iv: b64decode(iv) }, key, b64decode(ct))
68
+ return new TextDecoder().decode(plain)
69
+ }
70
+
71
+ /** Persist/restore our keypair across reloads (so prior ciphertext stays readable). */
72
+ export async function loadOrCreateKeyPair(storageKey: string): Promise<KeyPair> {
73
+ try {
74
+ const raw = globalThis.localStorage?.getItem(storageKey)
75
+ if (raw) {
76
+ const { pub, priv } = JSON.parse(raw) as { pub: JsonWebKey; priv: JsonWebKey }
77
+ const publicKey = await subtle().importKey('jwk', pub, { name: 'ECDH', namedCurve: 'P-256' }, true, [])
78
+ const privateKey = await subtle().importKey('jwk', priv, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits'])
79
+ return { publicKey, privateKey }
80
+ }
81
+ } catch { /* fall through to fresh keys */ }
82
+ const kp = await generateKeyPair()
83
+ try {
84
+ const pub = await subtle().exportKey('jwk', kp.publicKey)
85
+ const priv = await subtle().exportKey('jwk', kp.privateKey)
86
+ globalThis.localStorage?.setItem(storageKey, JSON.stringify({ pub, priv }))
87
+ } catch { /* non-persistent environment is fine */ }
88
+ return kp
89
+ }
90
+
91
+ // ── X3DH async E2E ────────────────────────────────────────────────────────────
92
+ // Extended Triple Diffie-Hellman (X3DH) allows encrypting to an *offline* peer
93
+ // using their published prekey bundle. This enables asynchronous E2E: the sender
94
+ // can encrypt before the recipient connects.
95
+ //
96
+ // Key roles:
97
+ // IK = long-term identity key (ECDH P-256, persistent in localStorage)
98
+ // SPK = signed prekey (ECDH P-256, rotated periodically, server-stored)
99
+ // OPK = one-time prekey (ECDH P-256, single-use pool, server-stored)
100
+ // EK = ephemeral key (ECDH P-256, generated per-message, discarded after)
101
+ //
102
+ // X3DH shared secret = KDF(DH(IK_s, SPK_r) || DH(EK, IK_r) || DH(EK, SPK_r) || DH(EK, OPK_r))
103
+ // Where _s = sender, _r = recipient.
104
+
105
+ /** Sign a prekey public key bytes using ECDSA P-256 SHA-256.
106
+ * The signingKey must be an ECDSA P-256 private key (not ECDH).
107
+ * In the full X3DH setup the identity key pair contains both an ECDH key
108
+ * (for DH) and an ECDSA key (for signing). We keep them separate here. */
109
+ export async function signPrekey(signingPrivateKey: CryptoKey, spkPublicKey: CryptoKey): Promise<string> {
110
+ const spkRaw = await subtle().exportKey('raw', spkPublicKey)
111
+ const sig = await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, signingPrivateKey, spkRaw)
112
+ return b64encode(sig)
113
+ }
114
+
115
+ /** Verify an SPK signature. verifyPublicKey must be an ECDSA P-256 public key. */
116
+ export async function verifyPrekeySignature(verifyPublicKeyB64: string, spkPublicKeyB64: string, signatureB64: string): Promise<boolean> {
117
+ try {
118
+ const verKey = await subtle().importKey('raw', b64decode(verifyPublicKeyB64), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify'])
119
+ return await subtle().verify({ name: 'ECDSA', hash: 'SHA-256' }, verKey, b64decode(signatureB64), b64decode(spkPublicKeyB64))
120
+ } catch { return false }
121
+ }
122
+
123
+ /** A full identity keypair for X3DH: ECDH key for DH computations + ECDSA key
124
+ * for signing prekeys. The two key objects share the same P-256 curve but have
125
+ * different usages, so Web Crypto treats them separately. */
126
+ export interface IdentityKeyPair {
127
+ ecdhKP: KeyPair // for DH in X3DH
128
+ ecdsaKP: { publicKey: CryptoKey; privateKey: CryptoKey } // for signing SPKs
129
+ /** The ECDH public key exported as base64 — used as the X3DH identity key. */
130
+ publicKeyB64: string
131
+ /** The ECDSA public key exported as base64 — used for SPK signature verification. */
132
+ sigPublicKeyB64: string
133
+ }
134
+
135
+ /** Generate a full X3DH identity keypair (ECDH + ECDSA on the same P-256 curve). */
136
+ export async function generateIdentityKeyPair(): Promise<IdentityKeyPair> {
137
+ const ecdhKP = await generateKeyPair()
138
+ const ecdsaKP = await subtle().generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
139
+ return {
140
+ ecdhKP,
141
+ ecdsaKP: { publicKey: ecdsaKP.publicKey, privateKey: ecdsaKP.privateKey },
142
+ publicKeyB64: await exportPublicKey(ecdhKP.publicKey),
143
+ sigPublicKeyB64: await exportPublicKey(ecdsaKP.publicKey),
144
+ }
145
+ }
146
+
147
+ /** Load or generate an identity keypair, persisting both components. */
148
+ export async function loadOrCreateIdentityKeyPair(storageKey: string): Promise<IdentityKeyPair> {
149
+ try {
150
+ const raw = globalThis.localStorage?.getItem(`${storageKey}-identity`)
151
+ if (raw) {
152
+ const d = JSON.parse(raw) as { ecdhPub: JsonWebKey; ecdhPriv: JsonWebKey; ecdsaPub: JsonWebKey; ecdsaPriv: JsonWebKey }
153
+ const ecdhPub = await subtle().importKey('jwk', d.ecdhPub, { name: 'ECDH', namedCurve: 'P-256' }, true, [])
154
+ const ecdhPriv = await subtle().importKey('jwk', d.ecdhPriv, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits'])
155
+ const ecdsaPub = await subtle().importKey('jwk', d.ecdsaPub, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'])
156
+ const ecdsaPriv = await subtle().importKey('jwk', d.ecdsaPriv, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign'])
157
+ return {
158
+ ecdhKP: { publicKey: ecdhPub, privateKey: ecdhPriv },
159
+ ecdsaKP: { publicKey: ecdsaPub, privateKey: ecdsaPriv },
160
+ publicKeyB64: await exportPublicKey(ecdhPub),
161
+ sigPublicKeyB64: await exportPublicKey(ecdsaPub),
162
+ }
163
+ }
164
+ } catch { /* generate fresh */ }
165
+ const ikp = await generateIdentityKeyPair()
166
+ try {
167
+ const ecdhPub = await subtle().exportKey('jwk', ikp.ecdhKP.publicKey)
168
+ const ecdhPriv = await subtle().exportKey('jwk', ikp.ecdhKP.privateKey)
169
+ const ecdsaPub = await subtle().exportKey('jwk', ikp.ecdsaKP.publicKey)
170
+ const ecdsaPriv = await subtle().exportKey('jwk', ikp.ecdsaKP.privateKey)
171
+ globalThis.localStorage?.setItem(`${storageKey}-identity`, JSON.stringify({ ecdhPub, ecdhPriv, ecdsaPub, ecdsaPriv }))
172
+ } catch { /* non-persistent ok */ }
173
+ return ikp
174
+ }
175
+
176
+ export interface X3DHBundle {
177
+ identityKey: string // base64 raw P-256 public key
178
+ signedPrekey: string // base64 raw P-256 public key
179
+ signedPrekeyId: string // opaque ID for key rotation tracking
180
+ signature: string // base64 ECDSA signature of SPK by IK
181
+ oneTimePrekey?: string // base64 raw P-256 public key (optional)
182
+ }
183
+
184
+ /** X3DH sender side: derive a shared key from the recipient's prekey bundle.
185
+ * Returns the shared AES-GCM key and the ephemeral public key to transmit. */
186
+ export async function x3dhSend(
187
+ senderIK: KeyPair,
188
+ recipientBundle: X3DHBundle,
189
+ ): Promise<{ sharedKey: CryptoKey; ephemeralPublicKey: string }> {
190
+ const ek = await generateKeyPair()
191
+ const epkB64 = await exportPublicKey(ek.publicKey)
192
+
193
+ // Import recipient keys for DH.
194
+ const ik_r = await importPeerPublicKey(recipientBundle.identityKey)
195
+ const spk_r = await importPeerPublicKey(recipientBundle.signedPrekey)
196
+ const opk_r = recipientBundle.oneTimePrekey ? await importPeerPublicKey(recipientBundle.oneTimePrekey) : null
197
+
198
+ // Four DH computations per spec (three if no OPK).
199
+ const dh1 = await rawDH(senderIK.privateKey, spk_r) // DH(IK_s, SPK_r)
200
+ const dh2 = await rawDH(ek.privateKey, ik_r) // DH(EK, IK_r)
201
+ const dh3 = await rawDH(ek.privateKey, spk_r) // DH(EK, SPK_r)
202
+ const dh4 = opk_r ? await rawDH(ek.privateKey, opk_r) : null // DH(EK, OPK_r)
203
+
204
+ const ikm = concatBuffers(dh1, dh2, dh3, ...(dh4 ? [dh4] : []))
205
+ const sharedKey = await hkdfDeriveKey(ikm)
206
+
207
+ return { sharedKey, ephemeralPublicKey: epkB64 }
208
+ }
209
+
210
+ /** X3DH recipient side: rederive the shared key from an init message.
211
+ * Returns the shared AES-GCM key. */
212
+ export async function x3dhReceive(
213
+ recipientIK: KeyPair,
214
+ recipientSPK: KeyPair,
215
+ senderIKb64: string,
216
+ ephemeralKeyB64: string,
217
+ recipientOPK?: KeyPair,
218
+ ): Promise<CryptoKey> {
219
+ const ik_s = await importPeerPublicKey(senderIKb64)
220
+ const ek_s = await importPeerPublicKey(ephemeralKeyB64)
221
+
222
+ const dh1 = await rawDH(recipientSPK.privateKey, ik_s) // DH(SPK_r, IK_s)
223
+ const dh2 = await rawDH(recipientIK.privateKey, ek_s) // DH(IK_r, EK)
224
+ const dh3 = await rawDH(recipientSPK.privateKey, ek_s) // DH(SPK_r, EK)
225
+ const dh4 = recipientOPK ? await rawDH(recipientOPK.privateKey, ek_s) : null
226
+
227
+ const ikm = concatBuffers(dh1, dh2, dh3, ...(dh4 ? [dh4] : []))
228
+ return hkdfDeriveKey(ikm)
229
+ }
230
+
231
+ async function rawDH(privateKey: CryptoKey, publicKey: CryptoKey): Promise<ArrayBuffer> {
232
+ return subtle().deriveBits({ name: 'ECDH', public: publicKey }, privateKey, 256)
233
+ }
234
+
235
+ function concatBuffers(...bufs: ArrayBuffer[]): ArrayBuffer {
236
+ const total = bufs.reduce((n, b) => n + b.byteLength, 0)
237
+ const out = new Uint8Array(total)
238
+ let offset = 0
239
+ for (const b of bufs) { out.set(new Uint8Array(b), offset); offset += b.byteLength }
240
+ return out.buffer
241
+ }
242
+
243
+ async function hkdfDeriveKey(ikm: ArrayBuffer): Promise<CryptoKey> {
244
+ const ikmKey = await subtle().importKey('raw', ikm, 'HKDF', false, ['deriveKey'])
245
+ return subtle().deriveKey(
246
+ { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(32), info: new TextEncoder().encode('ObjectChat X3DH v1') },
247
+ ikmKey,
248
+ { name: 'AES-GCM', length: 256 },
249
+ false,
250
+ ['encrypt', 'decrypt'],
251
+ )
252
+ }