@abraca/dabra 1.0.20 → 1.0.22

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,384 @@
1
+ /**
2
+ * DevicePairingChannel
3
+ *
4
+ * Enables cross-device identity pairing over an E2EE WebRTC data channel.
5
+ * Device A (approver) creates a pairing session with a short code. Device B
6
+ * (requester) joins with the code. After E2EE is established, Device B sends
7
+ * its public key and Device A registers it via `addKey()`.
8
+ *
9
+ * Reuses the existing WebRTC stack: SignalingSocket, PeerConnection,
10
+ * DataChannelRouter, and E2EEChannel (X25519 ECDH + AES-256-GCM).
11
+ */
12
+
13
+ import { sha256 } from "@noble/hashes/sha256";
14
+ import EventEmitter from "../EventEmitter.ts";
15
+ import type { AbracadabraClient } from "../AbracadabraClient.ts";
16
+ import { AbracadabraWebRTC } from "./AbracadabraWebRTC.ts";
17
+ import type { E2EEIdentity } from "./E2EEChannel.ts";
18
+
19
+ // ── Constants ───────────────────────────────────────────────────────────────
20
+
21
+ /** Ambiguity-free charset (no 0/O/1/I). */
22
+ const CODE_CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
23
+ const CODE_LENGTH = 6;
24
+ const PAIRING_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
25
+ const PAIRING_CHANNEL = "device-pairing";
26
+
27
+ // ── Types ───────────────────────────────────────────────────────────────────
28
+
29
+ export interface DevicePairingConfig {
30
+ /** Server base URL (http/https). */
31
+ serverUrl: string;
32
+ /** JWT token or async token factory for signaling auth. */
33
+ token: string | (() => string) | (() => Promise<string>);
34
+ /** E2EE identity (Ed25519 public key + X25519 private key). */
35
+ e2ee: E2EEIdentity;
36
+ /** ICE servers. Defaults to Google STUN. */
37
+ iceServers?: RTCIceServer[];
38
+ /** WebSocket polyfill (for Node.js). */
39
+ WebSocketPolyfill?: any;
40
+ }
41
+
42
+ export interface PairingRequest {
43
+ /** Device B's Ed25519 public key (base64url). */
44
+ publicKey: string;
45
+ /** Device B's X25519 public key (base64url). */
46
+ x25519Key: string;
47
+ /** Human-readable device label. */
48
+ deviceName: string;
49
+ }
50
+
51
+ export interface PairingResult {
52
+ success: boolean;
53
+ error?: string;
54
+ }
55
+
56
+ type PairingRole = "approver" | "requester";
57
+
58
+ // ── Internal wire messages ──────────────────────────────────────────────────
59
+
60
+ interface PairRequestMsg {
61
+ type: "pair-request";
62
+ publicKey: string;
63
+ x25519Key: string;
64
+ deviceName: string;
65
+ }
66
+
67
+ interface PairApprovedMsg {
68
+ type: "pair-approved";
69
+ }
70
+
71
+ interface PairInviteCodeMsg {
72
+ type: "pair-invite-code";
73
+ code: string;
74
+ }
75
+
76
+ interface PairRejectedMsg {
77
+ type: "pair-rejected";
78
+ reason: string;
79
+ }
80
+
81
+ type PairingMsg = PairRequestMsg | PairApprovedMsg | PairRejectedMsg | PairInviteCodeMsg;
82
+
83
+ // ── Helpers ─────────────────────────────────────────────────────────────────
84
+
85
+ function generatePairingCode(): string {
86
+ const bytes = crypto.getRandomValues(new Uint8Array(CODE_LENGTH));
87
+ return Array.from(bytes)
88
+ .map((b) => CODE_CHARSET[b % CODE_CHARSET.length])
89
+ .join("");
90
+ }
91
+
92
+ function codeToRoomId(code: string): string {
93
+ const hash = sha256(new TextEncoder().encode(code.toUpperCase()));
94
+ // Take first 16 hex chars for the room ID.
95
+ const hex = Array.from(hash.slice(0, 8))
96
+ .map((b) => b.toString(16).padStart(2, "0"))
97
+ .join("");
98
+ return `__pairing_${hex}`;
99
+ }
100
+
101
+ // ── DevicePairingChannel ────────────────────────────────────────────────────
102
+
103
+ export class DevicePairingChannel extends EventEmitter {
104
+ private webrtc: AbracadabraWebRTC | null = null;
105
+ private timeoutHandle: ReturnType<typeof setTimeout> | null = null;
106
+ private _destroyed = false;
107
+ private _pendingRequest: PairingRequest | null = null;
108
+ private _connectedPeerId: string | null = null;
109
+
110
+ readonly role: PairingRole;
111
+ readonly pairingCode: string;
112
+
113
+ private constructor(
114
+ role: PairingRole,
115
+ pairingCode: string,
116
+ private readonly config: DevicePairingConfig,
117
+ ) {
118
+ super();
119
+ this.role = role;
120
+ this.pairingCode = pairingCode;
121
+ }
122
+
123
+ // ── Factory Methods ───────────────────────────────────────────────────
124
+
125
+ /**
126
+ * Create an approver session (Device A). Returns the channel and a
127
+ * 6-character pairing code to share with Device B.
128
+ */
129
+ static createApprover(config: DevicePairingConfig): {
130
+ channel: DevicePairingChannel;
131
+ pairingCode: string;
132
+ } {
133
+ const code = generatePairingCode();
134
+ const channel = new DevicePairingChannel("approver", code, config);
135
+ channel.start();
136
+ return { channel, pairingCode: code };
137
+ }
138
+
139
+ /**
140
+ * Create a requester session (Device B). Joins with a pairing code
141
+ * obtained from Device A.
142
+ */
143
+ static createRequester(
144
+ config: DevicePairingConfig,
145
+ pairingCode: string,
146
+ ): DevicePairingChannel {
147
+ const channel = new DevicePairingChannel(
148
+ "requester",
149
+ pairingCode.toUpperCase().replace(/[^A-Z2-9]/g, ""),
150
+ config,
151
+ );
152
+ channel.start();
153
+ return channel;
154
+ }
155
+
156
+ // ── Approver API ──────────────────────────────────────────────────────
157
+
158
+ /**
159
+ * Approve the pending pairing request. Calls `client.addKey()` to
160
+ * register Device B's public key, then notifies Device B.
161
+ */
162
+ async approve(client: AbracadabraClient): Promise<PairingResult> {
163
+ if (this.role !== "approver") {
164
+ return { success: false, error: "Only the approver can approve" };
165
+ }
166
+ if (!this._pendingRequest) {
167
+ return { success: false, error: "No pending pairing request" };
168
+ }
169
+ if (!this._connectedPeerId) {
170
+ return { success: false, error: "Peer not connected" };
171
+ }
172
+
173
+ const req = this._pendingRequest;
174
+
175
+ try {
176
+ await client.addKey({
177
+ publicKey: req.publicKey,
178
+ deviceName: req.deviceName,
179
+ x25519Key: req.x25519Key,
180
+ });
181
+
182
+ this.sendMessage({ type: "pair-approved" });
183
+ this._pendingRequest = null;
184
+ this.emit("pairingComplete", { success: true } as PairingResult);
185
+ return { success: true };
186
+ } catch (err: any) {
187
+ const error = err?.message ?? "Failed to register device key";
188
+ this.sendMessage({ type: "pair-rejected", reason: error });
189
+ this._pendingRequest = null;
190
+ const result: PairingResult = { success: false, error };
191
+ this.emit("pairingComplete", result);
192
+ return result;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Approve via server-side device invite. Creates a single-use invite code
198
+ * and sends it to Device B over the E2EE channel. Device B redeems it
199
+ * independently via HTTP — Device A can go offline after this.
200
+ */
201
+ async approveWithInvite(client: AbracadabraClient): Promise<PairingResult> {
202
+ if (this.role !== "approver") {
203
+ return { success: false, error: "Only the approver can approve" };
204
+ }
205
+ if (!this._pendingRequest) {
206
+ return { success: false, error: "No pending pairing request" };
207
+ }
208
+ if (!this._connectedPeerId) {
209
+ return { success: false, error: "Peer not connected" };
210
+ }
211
+
212
+ try {
213
+ const { code } = await client.createDeviceInvite();
214
+ this.sendMessage({ type: "pair-invite-code", code });
215
+ this._pendingRequest = null;
216
+ this.emit("pairingComplete", { success: true } as PairingResult);
217
+ return { success: true };
218
+ } catch (err: any) {
219
+ const error = err?.message ?? "Failed to create device invite";
220
+ this.sendMessage({ type: "pair-rejected", reason: error });
221
+ this._pendingRequest = null;
222
+ const result: PairingResult = { success: false, error };
223
+ this.emit("pairingComplete", result);
224
+ return result;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Reject the pending pairing request.
230
+ */
231
+ reject(reason = "Rejected by user"): void {
232
+ if (this.role !== "approver" || !this._pendingRequest) return;
233
+ this.sendMessage({ type: "pair-rejected", reason });
234
+ this._pendingRequest = null;
235
+ this.emit("pairingComplete", {
236
+ success: false,
237
+ error: reason,
238
+ } as PairingResult);
239
+ }
240
+
241
+ // ── Requester API ─────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * Send a pairing request to Device A. Call this after the "connected"
245
+ * event fires.
246
+ */
247
+ requestPairing(request: PairingRequest): void {
248
+ if (this.role !== "requester") return;
249
+ this.sendMessage({
250
+ type: "pair-request",
251
+ publicKey: request.publicKey,
252
+ x25519Key: request.x25519Key,
253
+ deviceName: request.deviceName,
254
+ });
255
+ }
256
+
257
+ // ── Lifecycle ─────────────────────────────────────────────────────────
258
+
259
+ get isDestroyed(): boolean {
260
+ return this._destroyed;
261
+ }
262
+
263
+ destroy(): void {
264
+ if (this._destroyed) return;
265
+ this._destroyed = true;
266
+
267
+ if (this.timeoutHandle) {
268
+ clearTimeout(this.timeoutHandle);
269
+ this.timeoutHandle = null;
270
+ }
271
+
272
+ if (this.webrtc) {
273
+ this.webrtc.destroy();
274
+ this.webrtc = null;
275
+ }
276
+
277
+ this.removeAllListeners();
278
+ }
279
+
280
+ // ── Private ─────────────────────────────────────────────────────────
281
+
282
+ private start(): void {
283
+ const roomId = codeToRoomId(this.pairingCode);
284
+
285
+ this.webrtc = new AbracadabraWebRTC({
286
+ docId: roomId,
287
+ url: this.config.serverUrl,
288
+ token: this.config.token,
289
+ iceServers: this.config.iceServers,
290
+ e2ee: this.config.e2ee,
291
+ enableDocSync: false,
292
+ enableAwarenessSync: false,
293
+ enableFileTransfer: false,
294
+ autoConnect: false,
295
+ WebSocketPolyfill: this.config.WebSocketPolyfill,
296
+ });
297
+
298
+ this.webrtc.on("e2eeEstablished", ({ peerId }: { peerId: string }) => {
299
+ this._connectedPeerId = peerId;
300
+ this.emit("connected");
301
+ });
302
+
303
+ this.webrtc.on(
304
+ "customMessage",
305
+ ({ peerId, payload }: { peerId: string; payload: string }) => {
306
+ this.handleMessage(peerId, payload);
307
+ },
308
+ );
309
+
310
+ this.webrtc.on("peerLeft", () => {
311
+ if (!this._destroyed) {
312
+ this.emit("error", new Error("Peer disconnected"));
313
+ }
314
+ });
315
+
316
+ this.webrtc.on(
317
+ "signalingError",
318
+ (err: { code: string; message: string }) => {
319
+ this.emit("error", new Error(`Signaling: ${err.message}`));
320
+ },
321
+ );
322
+
323
+ // Auto-destroy after timeout.
324
+ this.timeoutHandle = setTimeout(() => {
325
+ if (!this._destroyed) {
326
+ this.emit("error", new Error("Pairing timed out"));
327
+ this.destroy();
328
+ }
329
+ }, PAIRING_TIMEOUT_MS);
330
+
331
+ this.webrtc.connect();
332
+ }
333
+
334
+ private sendMessage(msg: PairingMsg): void {
335
+ if (!this.webrtc || !this._connectedPeerId) return;
336
+ // Send via the custom message path — the E2EE layer on the data channel
337
+ // encrypts all non-key-exchange traffic via the DataChannelRouter.
338
+ this.webrtc.sendCustomMessage(
339
+ this._connectedPeerId,
340
+ JSON.stringify(msg),
341
+ );
342
+ }
343
+
344
+ private handleMessage(peerId: string, payload: string): void {
345
+ let msg: PairingMsg;
346
+ try {
347
+ msg = JSON.parse(payload);
348
+ } catch {
349
+ return;
350
+ }
351
+
352
+ switch (msg.type) {
353
+ case "pair-request":
354
+ if (this.role !== "approver") return;
355
+ this._pendingRequest = {
356
+ publicKey: msg.publicKey,
357
+ x25519Key: msg.x25519Key,
358
+ deviceName: msg.deviceName,
359
+ };
360
+ this.emit("pairingRequest", this._pendingRequest);
361
+ break;
362
+
363
+ case "pair-approved":
364
+ if (this.role !== "requester") return;
365
+ this.emit("approved");
366
+ this.emit("pairingComplete", { success: true } as PairingResult);
367
+ break;
368
+
369
+ case "pair-rejected":
370
+ if (this.role !== "requester") return;
371
+ this.emit("rejected", msg.reason);
372
+ this.emit("pairingComplete", {
373
+ success: false,
374
+ error: msg.reason,
375
+ } as PairingResult);
376
+ break;
377
+
378
+ case "pair-invite-code":
379
+ if (this.role !== "requester") return;
380
+ this.emit("inviteCode", msg.code);
381
+ break;
382
+ }
383
+ }
384
+ }
@@ -8,6 +8,8 @@ export { E2EEChannel } from "./E2EEChannel.ts";
8
8
  export type { E2EEIdentity } from "./E2EEChannel.ts";
9
9
  export { ManualSignaling } from "./ManualSignaling.ts";
10
10
  export type { ManualSignalingBlob } from "./ManualSignaling.ts";
11
+ export { DevicePairingChannel } from "./DevicePairingChannel.ts";
12
+ export type { DevicePairingConfig, PairingRequest, PairingResult } from "./DevicePairingChannel.ts";
11
13
  export type {
12
14
  AbracadabraWebRTCConfiguration,
13
15
  PeerInfo,