@abraca/dabra 1.0.20 → 1.0.21
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/dist/abracadabra-provider.cjs +293 -4
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +293 -5
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +104 -1
- package/package.json +1 -1
- package/src/AbracadabraClient.ts +27 -0
- package/src/types.ts +3 -0
- package/src/webrtc/AbracadabraWebRTC.ts +8 -4
- package/src/webrtc/DevicePairingChannel.ts +384 -0
- package/src/webrtc/index.ts +2 -0
package/dist/index.d.ts
CHANGED
|
@@ -315,8 +315,31 @@ declare class AbracadabraClient {
|
|
|
315
315
|
}): Promise<void>;
|
|
316
316
|
/** List all registered public keys for the current user. */
|
|
317
317
|
listKeys(): Promise<PublicKeyInfo[]>;
|
|
318
|
+
/** Rename a registered device key. */
|
|
319
|
+
renameKey(keyId: string, deviceName: string): Promise<void>;
|
|
318
320
|
/** Revoke a public key by its ID. */
|
|
319
321
|
revokeKey(keyId: string): Promise<void>;
|
|
322
|
+
/** Create a single-use device invite code for pairing a new device to this account. */
|
|
323
|
+
createDeviceInvite(opts?: {
|
|
324
|
+
expiresIn?: number;
|
|
325
|
+
}): Promise<{
|
|
326
|
+
code: string;
|
|
327
|
+
expiresAt: number;
|
|
328
|
+
}>;
|
|
329
|
+
/** Redeem a device invite code to register a new device key. Returns a JWT token. */
|
|
330
|
+
redeemDeviceInvite(opts: {
|
|
331
|
+
code: string;
|
|
332
|
+
publicKey: string;
|
|
333
|
+
x25519Key?: string;
|
|
334
|
+
deviceName?: string;
|
|
335
|
+
}): Promise<{
|
|
336
|
+
token: string;
|
|
337
|
+
user: {
|
|
338
|
+
id: string;
|
|
339
|
+
username: string;
|
|
340
|
+
publicKey: string;
|
|
341
|
+
};
|
|
342
|
+
}>;
|
|
320
343
|
/** Get encryption info for a document. */
|
|
321
344
|
getDocEncryption(docId: string): Promise<DocEncryptionInfo>;
|
|
322
345
|
/** Set the encryption mode for a document (no downgrade). */
|
|
@@ -1008,6 +1031,9 @@ interface PublicKeyInfo {
|
|
|
1008
1031
|
publicKey: string;
|
|
1009
1032
|
deviceName: string | null;
|
|
1010
1033
|
revoked: boolean;
|
|
1034
|
+
createdAt?: number | null;
|
|
1035
|
+
pairedBy?: string | null;
|
|
1036
|
+
pairedVia?: string | null;
|
|
1011
1037
|
}
|
|
1012
1038
|
interface PermissionEntry {
|
|
1013
1039
|
user_id: string;
|
|
@@ -2040,6 +2066,7 @@ declare class AbracadabraWebRTC extends EventEmitter {
|
|
|
2040
2066
|
broadcastFile(file: File | Blob, filename: string): Promise<FileTransferHandle[]>;
|
|
2041
2067
|
/**
|
|
2042
2068
|
* Send a custom string message to a specific peer via a data channel.
|
|
2069
|
+
* When E2EE is active, the message is encrypted through the router.
|
|
2043
2070
|
*/
|
|
2044
2071
|
sendCustomMessage(peerId: string, payload: string): void;
|
|
2045
2072
|
/**
|
|
@@ -2211,6 +2238,82 @@ declare class ManualSignaling extends EventEmitter {
|
|
|
2211
2238
|
destroy(): void;
|
|
2212
2239
|
}
|
|
2213
2240
|
//#endregion
|
|
2241
|
+
//#region packages/provider/src/webrtc/DevicePairingChannel.d.ts
|
|
2242
|
+
interface DevicePairingConfig {
|
|
2243
|
+
/** Server base URL (http/https). */
|
|
2244
|
+
serverUrl: string;
|
|
2245
|
+
/** JWT token or async token factory for signaling auth. */
|
|
2246
|
+
token: string | (() => string) | (() => Promise<string>);
|
|
2247
|
+
/** E2EE identity (Ed25519 public key + X25519 private key). */
|
|
2248
|
+
e2ee: E2EEIdentity;
|
|
2249
|
+
/** ICE servers. Defaults to Google STUN. */
|
|
2250
|
+
iceServers?: RTCIceServer[];
|
|
2251
|
+
/** WebSocket polyfill (for Node.js). */
|
|
2252
|
+
WebSocketPolyfill?: any;
|
|
2253
|
+
}
|
|
2254
|
+
interface PairingRequest {
|
|
2255
|
+
/** Device B's Ed25519 public key (base64url). */
|
|
2256
|
+
publicKey: string;
|
|
2257
|
+
/** Device B's X25519 public key (base64url). */
|
|
2258
|
+
x25519Key: string;
|
|
2259
|
+
/** Human-readable device label. */
|
|
2260
|
+
deviceName: string;
|
|
2261
|
+
}
|
|
2262
|
+
interface PairingResult {
|
|
2263
|
+
success: boolean;
|
|
2264
|
+
error?: string;
|
|
2265
|
+
}
|
|
2266
|
+
type PairingRole = "approver" | "requester";
|
|
2267
|
+
declare class DevicePairingChannel extends EventEmitter {
|
|
2268
|
+
private readonly config;
|
|
2269
|
+
private webrtc;
|
|
2270
|
+
private timeoutHandle;
|
|
2271
|
+
private _destroyed;
|
|
2272
|
+
private _pendingRequest;
|
|
2273
|
+
private _connectedPeerId;
|
|
2274
|
+
readonly role: PairingRole;
|
|
2275
|
+
readonly pairingCode: string;
|
|
2276
|
+
private constructor();
|
|
2277
|
+
/**
|
|
2278
|
+
* Create an approver session (Device A). Returns the channel and a
|
|
2279
|
+
* 6-character pairing code to share with Device B.
|
|
2280
|
+
*/
|
|
2281
|
+
static createApprover(config: DevicePairingConfig): {
|
|
2282
|
+
channel: DevicePairingChannel;
|
|
2283
|
+
pairingCode: string;
|
|
2284
|
+
};
|
|
2285
|
+
/**
|
|
2286
|
+
* Create a requester session (Device B). Joins with a pairing code
|
|
2287
|
+
* obtained from Device A.
|
|
2288
|
+
*/
|
|
2289
|
+
static createRequester(config: DevicePairingConfig, pairingCode: string): DevicePairingChannel;
|
|
2290
|
+
/**
|
|
2291
|
+
* Approve the pending pairing request. Calls `client.addKey()` to
|
|
2292
|
+
* register Device B's public key, then notifies Device B.
|
|
2293
|
+
*/
|
|
2294
|
+
approve(client: AbracadabraClient): Promise<PairingResult>;
|
|
2295
|
+
/**
|
|
2296
|
+
* Approve via server-side device invite. Creates a single-use invite code
|
|
2297
|
+
* and sends it to Device B over the E2EE channel. Device B redeems it
|
|
2298
|
+
* independently via HTTP — Device A can go offline after this.
|
|
2299
|
+
*/
|
|
2300
|
+
approveWithInvite(client: AbracadabraClient): Promise<PairingResult>;
|
|
2301
|
+
/**
|
|
2302
|
+
* Reject the pending pairing request.
|
|
2303
|
+
*/
|
|
2304
|
+
reject(reason?: string): void;
|
|
2305
|
+
/**
|
|
2306
|
+
* Send a pairing request to Device A. Call this after the "connected"
|
|
2307
|
+
* event fires.
|
|
2308
|
+
*/
|
|
2309
|
+
requestPairing(request: PairingRequest): void;
|
|
2310
|
+
get isDestroyed(): boolean;
|
|
2311
|
+
destroy(): void;
|
|
2312
|
+
private start;
|
|
2313
|
+
private sendMessage;
|
|
2314
|
+
private handleMessage;
|
|
2315
|
+
}
|
|
2316
|
+
//#endregion
|
|
2214
2317
|
//#region packages/provider/src/sync/BroadcastChannelSync.d.ts
|
|
2215
2318
|
declare class BroadcastChannelSync extends EventEmitter {
|
|
2216
2319
|
private readonly document;
|
|
@@ -2239,4 +2342,4 @@ declare class BroadcastChannelSync extends EventEmitter {
|
|
|
2239
2342
|
private handleAwarenessMessage;
|
|
2240
2343
|
}
|
|
2241
2344
|
//#endregion
|
|
2242
|
-
export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, CloseEvent, CompleteAbracadabraBaseProviderConfiguration, CompleteAbracadabraWSConfiguration, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, ConnectionTimeout, Constructable, ConstructableOutgoingMessage, CryptoIdentity, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, type DocEncryptionInfo, DocKeyManager, type DocSyncState, DocumentCache, type DocumentCacheOptions, DocumentMeta, E2EAbracadabraProvider, E2EEChannel, type E2EEIdentity, E2EOfflineStore, EffectivePermissionEntry, EffectivePermissionsResponse, EffectiveRole, EncryptedYMap, EncryptedYText, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, Forbidden, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, InviteRow, KEY_EXCHANGE_CHANNEL, ManualSignaling, type ManualSignalingBlob, MessageTooBig, MessageType, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, PeerConnection, type PeerInfo, type PeerState, PendingSubdoc, PermissionEntry, PublicKeyInfo, ResetConnection, SearchIndex, SearchResult, ServerInfo, type SignalingIncoming, type SignalingOutgoing, SignalingSocket, SpaceMeta, StatesArray, SubdocMessage, SubdocRegisteredEvent, Unauthorized, UploadInfo, UploadMeta, UploadQueueEntry, UploadQueueStatus, UserProfile, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, decryptField, encryptField, makeEncryptedYMap, makeEncryptedYText, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, readAuthMessage, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest };
|
|
2345
|
+
export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, CloseEvent, CompleteAbracadabraBaseProviderConfiguration, CompleteAbracadabraWSConfiguration, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, ConnectionTimeout, Constructable, ConstructableOutgoingMessage, CryptoIdentity, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, DevicePairingChannel, type DevicePairingConfig, type DocEncryptionInfo, DocKeyManager, type DocSyncState, DocumentCache, type DocumentCacheOptions, DocumentMeta, E2EAbracadabraProvider, E2EEChannel, type E2EEIdentity, E2EOfflineStore, EffectivePermissionEntry, EffectivePermissionsResponse, EffectiveRole, EncryptedYMap, EncryptedYText, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, Forbidden, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, InviteRow, KEY_EXCHANGE_CHANNEL, ManualSignaling, type ManualSignalingBlob, MessageTooBig, MessageType, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, type PairingRequest, type PairingResult, PeerConnection, type PeerInfo, type PeerState, PendingSubdoc, PermissionEntry, PublicKeyInfo, ResetConnection, SearchIndex, SearchResult, ServerInfo, type SignalingIncoming, type SignalingOutgoing, SignalingSocket, SpaceMeta, StatesArray, SubdocMessage, SubdocRegisteredEvent, Unauthorized, UploadInfo, UploadMeta, UploadQueueEntry, UploadQueueStatus, UserProfile, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, decryptField, encryptField, makeEncryptedYMap, makeEncryptedYText, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, readAuthMessage, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest };
|
package/package.json
CHANGED
package/src/AbracadabraClient.ts
CHANGED
|
@@ -195,11 +195,38 @@ export class AbracadabraClient {
|
|
|
195
195
|
return res.keys;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/** Rename a registered device key. */
|
|
199
|
+
async renameKey(keyId: string, deviceName: string): Promise<void> {
|
|
200
|
+
await this.request("PATCH", `/auth/keys/${encodeURIComponent(keyId)}`, { body: { deviceName } });
|
|
201
|
+
}
|
|
202
|
+
|
|
198
203
|
/** Revoke a public key by its ID. */
|
|
199
204
|
async revokeKey(keyId: string): Promise<void> {
|
|
200
205
|
await this.request("DELETE", `/auth/keys/${encodeURIComponent(keyId)}`);
|
|
201
206
|
}
|
|
202
207
|
|
|
208
|
+
// ── Device Invites ───────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/** Create a single-use device invite code for pairing a new device to this account. */
|
|
211
|
+
async createDeviceInvite(opts?: { expiresIn?: number }): Promise<{ code: string; expiresAt: number }> {
|
|
212
|
+
return this.request<{ code: string; expiresAt: number }>("POST", "/auth/device-invite", {
|
|
213
|
+
body: opts?.expiresIn != null ? { expiresIn: opts.expiresIn } : {},
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Redeem a device invite code to register a new device key. Returns a JWT token. */
|
|
218
|
+
async redeemDeviceInvite(opts: {
|
|
219
|
+
code: string;
|
|
220
|
+
publicKey: string;
|
|
221
|
+
x25519Key?: string;
|
|
222
|
+
deviceName?: string;
|
|
223
|
+
}): Promise<{ token: string; user: { id: string; username: string; publicKey: string } }> {
|
|
224
|
+
return this.request("POST", "/auth/device-redeem", {
|
|
225
|
+
body: { code: opts.code, publicKey: opts.publicKey, x25519Key: opts.x25519Key, deviceName: opts.deviceName },
|
|
226
|
+
auth: false,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
203
230
|
// ── Encryption ───────────────────────────────────────────────────────────
|
|
204
231
|
|
|
205
232
|
/** Get encryption info for a document. */
|
package/src/types.ts
CHANGED
|
@@ -335,22 +335,26 @@ export class AbracadabraWebRTC extends EventEmitter {
|
|
|
335
335
|
|
|
336
336
|
/**
|
|
337
337
|
* Send a custom string message to a specific peer via a data channel.
|
|
338
|
+
* When E2EE is active, the message is encrypted through the router.
|
|
338
339
|
*/
|
|
339
340
|
sendCustomMessage(peerId: string, payload: string): void {
|
|
340
341
|
const pc = this.peerConnections.get(peerId);
|
|
341
342
|
if (!pc) return;
|
|
342
343
|
|
|
343
|
-
|
|
344
|
+
const channelName = "custom";
|
|
345
|
+
let channel = pc.router.getChannel(channelName);
|
|
344
346
|
if (!channel || channel.readyState !== "open") {
|
|
345
347
|
// Create on-demand.
|
|
346
|
-
channel = pc.router.createChannel(
|
|
348
|
+
channel = pc.router.createChannel(channelName, { ordered: true });
|
|
347
349
|
// Wait for open before sending.
|
|
348
350
|
channel.onopen = () => {
|
|
349
|
-
|
|
351
|
+
const data = new TextEncoder().encode(payload);
|
|
352
|
+
pc.router.send(channelName, data);
|
|
350
353
|
};
|
|
351
354
|
return;
|
|
352
355
|
}
|
|
353
|
-
|
|
356
|
+
const data = new TextEncoder().encode(payload);
|
|
357
|
+
pc.router.send(channelName, data);
|
|
354
358
|
}
|
|
355
359
|
|
|
356
360
|
/**
|
|
@@ -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
|
+
}
|
package/src/webrtc/index.ts
CHANGED
|
@@ -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,
|