@paramms/chat-widget 0.1.0 → 1.0.1
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.
- package/README.md +138 -12
- package/dist/connection.d.ts +48 -0
- package/dist/crypto.d.ts +69 -0
- package/dist/e2e.d.ts +75 -0
- package/dist/history.d.ts +14 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +2638 -0
- package/dist/index.js.map +1 -0
- package/dist/outbox.d.ts +20 -0
- package/dist/protocol/actions.d.ts +98 -0
- package/dist/protocol/codec.d.ts +12 -0
- package/dist/protocol/entities.d.ts +109 -0
- package/dist/protocol/frames.d.ts +248 -0
- package/dist/protocol/ids.d.ts +22 -0
- package/dist/protocol/index.d.ts +5 -0
- package/dist/react.d.ts +124 -0
- package/dist/react.js +143 -0
- package/dist/react.js.map +1 -0
- package/dist/renderer.d.ts +163 -0
- package/dist/store.d.ts +48 -0
- package/package.json +31 -2
- package/build-preview.js +0 -136
- package/index.html +0 -37
- package/src/__tests__/chatlist.test.ts +0 -133
- package/src/__tests__/connection.test.ts +0 -163
- package/src/__tests__/crypto.test.ts +0 -28
- package/src/__tests__/history.test.ts +0 -91
- package/src/__tests__/ime.test.ts +0 -93
- package/src/__tests__/render.test.ts +0 -58
- package/src/__tests__/render_new.test.ts +0 -441
- package/src/__tests__/store.test.ts +0 -86
- package/src/__tests__/x3dh.test.ts +0 -204
- package/src/connection.ts +0 -133
- package/src/crypto.ts +0 -252
- package/src/e2e.ts +0 -161
- package/src/history.ts +0 -43
- package/src/index.ts +0 -380
- package/src/outbox.ts +0 -58
- package/src/protocol/actions.ts +0 -114
- package/src/protocol/codec.ts +0 -35
- package/src/protocol/entities.ts +0 -104
- package/src/protocol/frames.ts +0 -86
- package/src/protocol/ids.ts +0 -27
- package/src/protocol/index.ts +0 -5
- package/src/react.tsx +0 -37
- package/src/renderer.ts +0 -906
- package/src/store.ts +0 -207
- package/tsconfig.json +0 -33
- package/vercel.json +0 -22
- package/vite.config.ts +0 -26
- package/vitest.config.ts +0 -2
|
@@ -1,204 +0,0 @@
|
|
|
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
|
-
|
package/src/connection.ts
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,252 +0,0 @@
|
|
|
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
|
-
}
|