@abraca/dabra 1.0.23 → 1.0.25
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 +280 -12
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +280 -13
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +87 -2
- package/package.json +1 -1
- package/src/DeviceRegistrationService.ts +243 -0
- package/src/IdentityDoc.ts +143 -1
- package/src/index.ts +4 -0
- package/src/webrtc/AbracadabraWebRTC.ts +32 -15
- package/src/webrtc/DevicePairingChannel.ts +12 -0
package/dist/index.d.ts
CHANGED
|
@@ -2396,6 +2396,23 @@ interface IdentityServerEntry {
|
|
|
2396
2396
|
spacesEnabled?: boolean;
|
|
2397
2397
|
addedAt: number;
|
|
2398
2398
|
}
|
|
2399
|
+
type DeviceTier = "master" | "paired";
|
|
2400
|
+
interface DeviceServerStatus {
|
|
2401
|
+
registered: boolean;
|
|
2402
|
+
registeredAt: number | null;
|
|
2403
|
+
keyId: string | null;
|
|
2404
|
+
lastVerifiedAt: number | null;
|
|
2405
|
+
error: string | null;
|
|
2406
|
+
}
|
|
2407
|
+
interface IdentityDeviceEntry {
|
|
2408
|
+
deviceName: string;
|
|
2409
|
+
tier: DeviceTier;
|
|
2410
|
+
x25519Key: string | null;
|
|
2411
|
+
addedAt: number;
|
|
2412
|
+
revokedAt: number | null;
|
|
2413
|
+
revokedBy: string | null;
|
|
2414
|
+
servers: Map<string, DeviceServerStatus>;
|
|
2415
|
+
}
|
|
2399
2416
|
interface IdentitySpaceEntry {
|
|
2400
2417
|
id: string;
|
|
2401
2418
|
name: string;
|
|
@@ -2481,6 +2498,7 @@ declare class IdentityDocProvider extends EventEmitter {
|
|
|
2481
2498
|
get serversMap(): Y.Map<Y.Map<any>>;
|
|
2482
2499
|
get spacesArray(): Y.Array<Y.Map<any>>;
|
|
2483
2500
|
get pluginsMap(): Y.Map<any>;
|
|
2501
|
+
get devicesMap(): Y.Map<Y.Map<any>>;
|
|
2484
2502
|
get preferencesMap(): Y.Map<any>;
|
|
2485
2503
|
getProfile(): IdentityProfile;
|
|
2486
2504
|
setProfile(profile: Partial<IdentityProfile>): void;
|
|
@@ -2496,11 +2514,22 @@ declare class IdentityDocProvider extends EventEmitter {
|
|
|
2496
2514
|
getDisabledBuiltins(): Y.Array<string>;
|
|
2497
2515
|
getPreference<T = any>(key: string): T | undefined;
|
|
2498
2516
|
setPreference(key: string, value: any): void;
|
|
2517
|
+
getDevice(publicKey: string): IdentityDeviceEntry | undefined;
|
|
2518
|
+
getDevices(): Map<string, IdentityDeviceEntry>;
|
|
2519
|
+
addDevice(publicKey: string, opts: {
|
|
2520
|
+
deviceName: string;
|
|
2521
|
+
tier: DeviceTier;
|
|
2522
|
+
x25519Key?: string;
|
|
2523
|
+
serverUrl?: string;
|
|
2524
|
+
keyId?: string;
|
|
2525
|
+
}): void;
|
|
2526
|
+
revokeDevice(publicKey: string, revokedBy: string): void;
|
|
2527
|
+
setDeviceServerStatus(devicePubKey: string, serverUrl: string, status: Partial<DeviceServerStatus>): void;
|
|
2499
2528
|
/**
|
|
2500
2529
|
* Observe deep changes on a specific top-level map.
|
|
2501
2530
|
* Returns an unsubscribe function.
|
|
2502
2531
|
*/
|
|
2503
|
-
observe(mapName: "profile" | "servers" | "plugins" | "preferences", callback: (events: Y.YEvent<any>[], transaction: Y.Transaction) => void): () => void;
|
|
2532
|
+
observe(mapName: "profile" | "servers" | "plugins" | "preferences" | "devices", callback: (events: Y.YEvent<any>[], transaction: Y.Transaction) => void): () => void;
|
|
2504
2533
|
/**
|
|
2505
2534
|
* Observe changes to the spaces array.
|
|
2506
2535
|
* Returns an unsubscribe function.
|
|
@@ -2520,4 +2549,60 @@ declare class IdentityDocProvider extends EventEmitter {
|
|
|
2520
2549
|
destroy(): void;
|
|
2521
2550
|
}
|
|
2522
2551
|
//#endregion
|
|
2523
|
-
|
|
2552
|
+
//#region packages/provider/src/DeviceRegistrationService.d.ts
|
|
2553
|
+
interface FanOutResult {
|
|
2554
|
+
serverUrl: string;
|
|
2555
|
+
success: boolean;
|
|
2556
|
+
error?: string;
|
|
2557
|
+
}
|
|
2558
|
+
/**
|
|
2559
|
+
* Handles cross-server device key registration and revocation propagation.
|
|
2560
|
+
*
|
|
2561
|
+
* All methods operate on the identity Y.Doc's `devicesMap` and authenticate
|
|
2562
|
+
* independently on each server using the master key's `signChallenge`.
|
|
2563
|
+
*/
|
|
2564
|
+
declare class DeviceRegistrationService {
|
|
2565
|
+
/**
|
|
2566
|
+
* Fan out a device key to all servers in `serversMap` where it isn't registered.
|
|
2567
|
+
*
|
|
2568
|
+
* Must be called from a master device that can sign challenges with the
|
|
2569
|
+
* account-level private key.
|
|
2570
|
+
*/
|
|
2571
|
+
static fanOutDeviceKey(opts: {
|
|
2572
|
+
devicePubKey: string;
|
|
2573
|
+
x25519Key?: string;
|
|
2574
|
+
deviceName?: string;
|
|
2575
|
+
identityDoc: IdentityDocProvider;
|
|
2576
|
+
masterPubKey: string;
|
|
2577
|
+
signChallenge: (challenge: string) => Promise<string>;
|
|
2578
|
+
skipServerUrl?: string;
|
|
2579
|
+
}): Promise<FanOutResult[]>;
|
|
2580
|
+
/**
|
|
2581
|
+
* Propagate pending revocations from the identity Y.Doc to all servers.
|
|
2582
|
+
*
|
|
2583
|
+
* Scans `devicesMap` for entries with `revokedAt` set and revokes them
|
|
2584
|
+
* on every server where they're still registered. Must be called from
|
|
2585
|
+
* a master device.
|
|
2586
|
+
*/
|
|
2587
|
+
static propagateRevocations(opts: {
|
|
2588
|
+
identityDoc: IdentityDocProvider;
|
|
2589
|
+
masterPubKey: string;
|
|
2590
|
+
signChallenge: (challenge: string) => Promise<string>;
|
|
2591
|
+
}): Promise<FanOutResult[]>;
|
|
2592
|
+
/**
|
|
2593
|
+
* Reconcile the identity Y.Doc's `devicesMap` with a server's actual
|
|
2594
|
+
* device key list. Call this on every server connection.
|
|
2595
|
+
*
|
|
2596
|
+
* - Updates registration status in Y.Doc to match server reality
|
|
2597
|
+
* - Imports unknown server keys as legacy entries
|
|
2598
|
+
* - Executes pending revocations if the caller is a master device
|
|
2599
|
+
*/
|
|
2600
|
+
static reconcile(opts: {
|
|
2601
|
+
identityDoc: IdentityDocProvider;
|
|
2602
|
+
serverUrl: string;
|
|
2603
|
+
client: AbracadabraClient;
|
|
2604
|
+
isMaster: boolean;
|
|
2605
|
+
}): Promise<void>;
|
|
2606
|
+
}
|
|
2607
|
+
//#endregion
|
|
2608
|
+
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, DeviceRegistrationService, type DeviceServerStatus, type DeviceTier, 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, type IdentityDeviceEntry, type IdentityDocConfiguration, IdentityDocProvider, type IdentityProfile, type IdentityServerEntry, type IdentitySpaceEntry, 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, deriveIdentityDocId, 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
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type * as Y from "yjs";
|
|
2
|
+
import { AbracadabraClient } from "./AbracadabraClient.ts";
|
|
3
|
+
import type { IdentityDocProvider } from "./IdentityDoc.ts";
|
|
4
|
+
import type { PublicKeyInfo } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface FanOutResult {
|
|
9
|
+
serverUrl: string;
|
|
10
|
+
success: boolean;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ── DeviceRegistrationService ────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Handles cross-server device key registration and revocation propagation.
|
|
18
|
+
*
|
|
19
|
+
* All methods operate on the identity Y.Doc's `devicesMap` and authenticate
|
|
20
|
+
* independently on each server using the master key's `signChallenge`.
|
|
21
|
+
*/
|
|
22
|
+
export class DeviceRegistrationService {
|
|
23
|
+
/**
|
|
24
|
+
* Fan out a device key to all servers in `serversMap` where it isn't registered.
|
|
25
|
+
*
|
|
26
|
+
* Must be called from a master device that can sign challenges with the
|
|
27
|
+
* account-level private key.
|
|
28
|
+
*/
|
|
29
|
+
static async fanOutDeviceKey(opts: {
|
|
30
|
+
devicePubKey: string;
|
|
31
|
+
x25519Key?: string;
|
|
32
|
+
deviceName?: string;
|
|
33
|
+
identityDoc: IdentityDocProvider;
|
|
34
|
+
masterPubKey: string;
|
|
35
|
+
signChallenge: (challenge: string) => Promise<string>;
|
|
36
|
+
skipServerUrl?: string;
|
|
37
|
+
}): Promise<FanOutResult[]> {
|
|
38
|
+
const {
|
|
39
|
+
devicePubKey,
|
|
40
|
+
x25519Key,
|
|
41
|
+
deviceName,
|
|
42
|
+
identityDoc,
|
|
43
|
+
masterPubKey,
|
|
44
|
+
signChallenge,
|
|
45
|
+
skipServerUrl,
|
|
46
|
+
} = opts;
|
|
47
|
+
|
|
48
|
+
const servers = identityDoc.getServers();
|
|
49
|
+
const results: FanOutResult[] = [];
|
|
50
|
+
|
|
51
|
+
// Ensure the device entry exists in devicesMap
|
|
52
|
+
if (!identityDoc.getDevice(devicePubKey)) {
|
|
53
|
+
identityDoc.addDevice(devicePubKey, {
|
|
54
|
+
deviceName: deviceName ?? "Unknown",
|
|
55
|
+
tier: "paired",
|
|
56
|
+
x25519Key,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const [serverUrl] of servers) {
|
|
61
|
+
if (skipServerUrl && serverUrl === skipServerUrl) continue;
|
|
62
|
+
|
|
63
|
+
// Check if already registered on this server
|
|
64
|
+
const device = identityDoc.getDevice(devicePubKey);
|
|
65
|
+
const serverStatus = device?.servers.get(serverUrl);
|
|
66
|
+
if (serverStatus?.registered) continue;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const client = new AbracadabraClient({ url: serverUrl });
|
|
70
|
+
await client.loginWithKey(masterPubKey, signChallenge);
|
|
71
|
+
await client.addKey({
|
|
72
|
+
publicKey: devicePubKey,
|
|
73
|
+
x25519Key,
|
|
74
|
+
deviceName,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Get the keyId from the server for future revocation
|
|
78
|
+
const keys = await client.listKeys();
|
|
79
|
+
const match = keys.find(
|
|
80
|
+
(k) => k.publicKey === devicePubKey && !k.revoked,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
identityDoc.setDeviceServerStatus(devicePubKey, serverUrl, {
|
|
84
|
+
registered: true,
|
|
85
|
+
registeredAt: Date.now(),
|
|
86
|
+
keyId: match?.id ?? null,
|
|
87
|
+
lastVerifiedAt: Date.now(),
|
|
88
|
+
error: null,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
results.push({ serverUrl, success: true });
|
|
92
|
+
} catch (e: any) {
|
|
93
|
+
identityDoc.setDeviceServerStatus(devicePubKey, serverUrl, {
|
|
94
|
+
error: e?.message ?? "Registration failed",
|
|
95
|
+
});
|
|
96
|
+
results.push({
|
|
97
|
+
serverUrl,
|
|
98
|
+
success: false,
|
|
99
|
+
error: e?.message ?? "Registration failed",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return results;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Propagate pending revocations from the identity Y.Doc to all servers.
|
|
109
|
+
*
|
|
110
|
+
* Scans `devicesMap` for entries with `revokedAt` set and revokes them
|
|
111
|
+
* on every server where they're still registered. Must be called from
|
|
112
|
+
* a master device.
|
|
113
|
+
*/
|
|
114
|
+
static async propagateRevocations(opts: {
|
|
115
|
+
identityDoc: IdentityDocProvider;
|
|
116
|
+
masterPubKey: string;
|
|
117
|
+
signChallenge: (challenge: string) => Promise<string>;
|
|
118
|
+
}): Promise<FanOutResult[]> {
|
|
119
|
+
const { identityDoc, masterPubKey, signChallenge } = opts;
|
|
120
|
+
const devices = identityDoc.getDevices();
|
|
121
|
+
const results: FanOutResult[] = [];
|
|
122
|
+
|
|
123
|
+
for (const [devicePubKey, device] of devices) {
|
|
124
|
+
if (!device.revokedAt) continue;
|
|
125
|
+
|
|
126
|
+
for (const [serverUrl, serverStatus] of device.servers) {
|
|
127
|
+
if (!serverStatus.registered || !serverStatus.keyId) continue;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const client = new AbracadabraClient({ url: serverUrl });
|
|
131
|
+
await client.loginWithKey(masterPubKey, signChallenge);
|
|
132
|
+
await client.revokeKey(serverStatus.keyId);
|
|
133
|
+
|
|
134
|
+
identityDoc.setDeviceServerStatus(
|
|
135
|
+
devicePubKey,
|
|
136
|
+
serverUrl,
|
|
137
|
+
{
|
|
138
|
+
registered: false,
|
|
139
|
+
error: null,
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
results.push({ serverUrl, success: true });
|
|
143
|
+
} catch (e: any) {
|
|
144
|
+
results.push({
|
|
145
|
+
serverUrl,
|
|
146
|
+
success: false,
|
|
147
|
+
error: e?.message ?? "Revocation failed",
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return results;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Reconcile the identity Y.Doc's `devicesMap` with a server's actual
|
|
158
|
+
* device key list. Call this on every server connection.
|
|
159
|
+
*
|
|
160
|
+
* - Updates registration status in Y.Doc to match server reality
|
|
161
|
+
* - Imports unknown server keys as legacy entries
|
|
162
|
+
* - Executes pending revocations if the caller is a master device
|
|
163
|
+
*/
|
|
164
|
+
static async reconcile(opts: {
|
|
165
|
+
identityDoc: IdentityDocProvider;
|
|
166
|
+
serverUrl: string;
|
|
167
|
+
client: AbracadabraClient;
|
|
168
|
+
isMaster: boolean;
|
|
169
|
+
}): Promise<void> {
|
|
170
|
+
const { identityDoc, serverUrl, client, isMaster } = opts;
|
|
171
|
+
|
|
172
|
+
let serverKeys: PublicKeyInfo[];
|
|
173
|
+
try {
|
|
174
|
+
serverKeys = await client.listKeys();
|
|
175
|
+
} catch {
|
|
176
|
+
return; // Server unreachable or auth issue — skip reconciliation
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const devicesMap = identityDoc.devicesMap;
|
|
180
|
+
|
|
181
|
+
// 1. Verify Y.Doc matches server reality
|
|
182
|
+
devicesMap.forEach((yEntry, devicePub) => {
|
|
183
|
+
const serversYMap = yEntry.get("servers") as
|
|
184
|
+
| Y.Map<Y.Map<any>>
|
|
185
|
+
| undefined;
|
|
186
|
+
const serverEntry = serversYMap?.get(serverUrl);
|
|
187
|
+
const serverKey = serverKeys.find((k) => k.publicKey === devicePub);
|
|
188
|
+
const revokedAt = yEntry.get("revokedAt");
|
|
189
|
+
|
|
190
|
+
if (serverEntry?.get("registered") && !serverKey) {
|
|
191
|
+
// Y.Doc thinks registered but server doesn't have it
|
|
192
|
+
identityDoc.setDeviceServerStatus(devicePub, serverUrl, {
|
|
193
|
+
registered: false,
|
|
194
|
+
keyId: null,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (serverKey && !serverKey.revoked) {
|
|
199
|
+
// Server has it — update Y.Doc if needed
|
|
200
|
+
if (!serverEntry?.get("registered")) {
|
|
201
|
+
identityDoc.setDeviceServerStatus(devicePub, serverUrl, {
|
|
202
|
+
registered: true,
|
|
203
|
+
keyId: serverKey.id,
|
|
204
|
+
lastVerifiedAt: Date.now(),
|
|
205
|
+
});
|
|
206
|
+
} else {
|
|
207
|
+
// Just update verification timestamp + keyId
|
|
208
|
+
identityDoc.setDeviceServerStatus(devicePub, serverUrl, {
|
|
209
|
+
lastVerifiedAt: Date.now(),
|
|
210
|
+
keyId: serverKey.id,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Execute pending revocation if we're a master device
|
|
215
|
+
if (revokedAt && isMaster) {
|
|
216
|
+
client
|
|
217
|
+
.revokeKey(serverKey.id)
|
|
218
|
+
.then(() => {
|
|
219
|
+
identityDoc.setDeviceServerStatus(
|
|
220
|
+
devicePub,
|
|
221
|
+
serverUrl,
|
|
222
|
+
{ registered: false },
|
|
223
|
+
);
|
|
224
|
+
})
|
|
225
|
+
.catch(() => {});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// 2. Import unknown server keys into Y.Doc (migration)
|
|
231
|
+
for (const key of serverKeys) {
|
|
232
|
+
if (key.revoked) continue;
|
|
233
|
+
if (!devicesMap.has(key.publicKey)) {
|
|
234
|
+
identityDoc.addDevice(key.publicKey, {
|
|
235
|
+
deviceName: key.deviceName ?? "Unknown",
|
|
236
|
+
tier: "paired",
|
|
237
|
+
serverUrl,
|
|
238
|
+
keyId: key.id,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
package/src/IdentityDoc.ts
CHANGED
|
@@ -53,6 +53,26 @@ export interface IdentityServerEntry {
|
|
|
53
53
|
addedAt: number;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export type DeviceTier = "master" | "paired";
|
|
57
|
+
|
|
58
|
+
export interface DeviceServerStatus {
|
|
59
|
+
registered: boolean;
|
|
60
|
+
registeredAt: number | null;
|
|
61
|
+
keyId: string | null;
|
|
62
|
+
lastVerifiedAt: number | null;
|
|
63
|
+
error: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface IdentityDeviceEntry {
|
|
67
|
+
deviceName: string;
|
|
68
|
+
tier: DeviceTier;
|
|
69
|
+
x25519Key: string | null;
|
|
70
|
+
addedAt: number;
|
|
71
|
+
revokedAt: number | null;
|
|
72
|
+
revokedBy: string | null;
|
|
73
|
+
servers: Map<string, DeviceServerStatus>;
|
|
74
|
+
}
|
|
75
|
+
|
|
56
76
|
export interface IdentitySpaceEntry {
|
|
57
77
|
id: string;
|
|
58
78
|
name: string;
|
|
@@ -275,6 +295,10 @@ export class IdentityDocProvider extends EventEmitter {
|
|
|
275
295
|
return this.document.getMap("plugins");
|
|
276
296
|
}
|
|
277
297
|
|
|
298
|
+
get devicesMap(): Y.Map<Y.Map<any>> {
|
|
299
|
+
return this.document.getMap("devices");
|
|
300
|
+
}
|
|
301
|
+
|
|
278
302
|
get preferencesMap(): Y.Map<any> {
|
|
279
303
|
return this.document.getMap("preferences");
|
|
280
304
|
}
|
|
@@ -445,6 +469,124 @@ export class IdentityDocProvider extends EventEmitter {
|
|
|
445
469
|
this.preferencesMap.set(key, value);
|
|
446
470
|
}
|
|
447
471
|
|
|
472
|
+
// ── Devices ─────────────────────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
getDevice(publicKey: string): IdentityDeviceEntry | undefined {
|
|
475
|
+
const entry = this.devicesMap.get(publicKey);
|
|
476
|
+
if (!entry) return undefined;
|
|
477
|
+
const servers = new Map<string, DeviceServerStatus>();
|
|
478
|
+
const serversYMap = entry.get("servers") as Y.Map<Y.Map<any>> | undefined;
|
|
479
|
+
if (serversYMap) {
|
|
480
|
+
serversYMap.forEach((sEntry, url) => {
|
|
481
|
+
servers.set(url, {
|
|
482
|
+
registered: sEntry.get("registered") ?? false,
|
|
483
|
+
registeredAt: sEntry.get("registeredAt") ?? null,
|
|
484
|
+
keyId: sEntry.get("keyId") ?? null,
|
|
485
|
+
lastVerifiedAt: sEntry.get("lastVerifiedAt") ?? null,
|
|
486
|
+
error: sEntry.get("error") ?? null,
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
deviceName: entry.get("deviceName") ?? "Unknown",
|
|
492
|
+
tier: entry.get("tier") ?? "paired",
|
|
493
|
+
x25519Key: entry.get("x25519Key") ?? null,
|
|
494
|
+
addedAt: entry.get("addedAt") ?? 0,
|
|
495
|
+
revokedAt: entry.get("revokedAt") ?? null,
|
|
496
|
+
revokedBy: entry.get("revokedBy") ?? null,
|
|
497
|
+
servers,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
getDevices(): Map<string, IdentityDeviceEntry> {
|
|
502
|
+
const result = new Map<string, IdentityDeviceEntry>();
|
|
503
|
+
this.devicesMap.forEach((_entry, pubKey) => {
|
|
504
|
+
const device = this.getDevice(pubKey);
|
|
505
|
+
if (device) result.set(pubKey, device);
|
|
506
|
+
});
|
|
507
|
+
return result;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
addDevice(
|
|
511
|
+
publicKey: string,
|
|
512
|
+
opts: {
|
|
513
|
+
deviceName: string;
|
|
514
|
+
tier: DeviceTier;
|
|
515
|
+
x25519Key?: string;
|
|
516
|
+
serverUrl?: string;
|
|
517
|
+
keyId?: string;
|
|
518
|
+
},
|
|
519
|
+
): void {
|
|
520
|
+
this.document.transact(() => {
|
|
521
|
+
let entry = this.devicesMap.get(publicKey);
|
|
522
|
+
if (!entry) {
|
|
523
|
+
entry = new Y.Map();
|
|
524
|
+
this.devicesMap.set(publicKey, entry);
|
|
525
|
+
}
|
|
526
|
+
entry.set("deviceName", opts.deviceName);
|
|
527
|
+
entry.set("tier", opts.tier);
|
|
528
|
+
if (opts.x25519Key) entry.set("x25519Key", opts.x25519Key);
|
|
529
|
+
entry.set("addedAt", Date.now());
|
|
530
|
+
entry.set("revokedAt", null);
|
|
531
|
+
entry.set("revokedBy", null);
|
|
532
|
+
|
|
533
|
+
if (opts.serverUrl) {
|
|
534
|
+
let servers = entry.get("servers") as Y.Map<Y.Map<any>> | undefined;
|
|
535
|
+
if (!servers) {
|
|
536
|
+
servers = new Y.Map();
|
|
537
|
+
entry.set("servers", servers);
|
|
538
|
+
}
|
|
539
|
+
const sEntry = new Y.Map();
|
|
540
|
+
sEntry.set("registered", true);
|
|
541
|
+
sEntry.set("registeredAt", Date.now());
|
|
542
|
+
sEntry.set("keyId", opts.keyId ?? null);
|
|
543
|
+
sEntry.set("lastVerifiedAt", Date.now());
|
|
544
|
+
sEntry.set("error", null);
|
|
545
|
+
servers.set(opts.serverUrl, sEntry);
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
revokeDevice(publicKey: string, revokedBy: string): void {
|
|
551
|
+
const entry = this.devicesMap.get(publicKey);
|
|
552
|
+
if (!entry) return;
|
|
553
|
+
this.document.transact(() => {
|
|
554
|
+
entry.set("revokedAt", Date.now());
|
|
555
|
+
entry.set("revokedBy", revokedBy);
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
setDeviceServerStatus(
|
|
560
|
+
devicePubKey: string,
|
|
561
|
+
serverUrl: string,
|
|
562
|
+
status: Partial<DeviceServerStatus>,
|
|
563
|
+
): void {
|
|
564
|
+
const entry = this.devicesMap.get(devicePubKey);
|
|
565
|
+
if (!entry) return;
|
|
566
|
+
this.document.transact(() => {
|
|
567
|
+
let servers = entry.get("servers") as Y.Map<Y.Map<any>> | undefined;
|
|
568
|
+
if (!servers) {
|
|
569
|
+
servers = new Y.Map();
|
|
570
|
+
entry.set("servers", servers);
|
|
571
|
+
}
|
|
572
|
+
let sEntry = servers.get(serverUrl);
|
|
573
|
+
if (!sEntry) {
|
|
574
|
+
sEntry = new Y.Map();
|
|
575
|
+
sEntry.set("registered", false);
|
|
576
|
+
sEntry.set("registeredAt", null);
|
|
577
|
+
sEntry.set("keyId", null);
|
|
578
|
+
sEntry.set("lastVerifiedAt", null);
|
|
579
|
+
sEntry.set("error", null);
|
|
580
|
+
servers.set(serverUrl, sEntry);
|
|
581
|
+
}
|
|
582
|
+
for (const [key, value] of Object.entries(status)) {
|
|
583
|
+
if (value !== undefined) {
|
|
584
|
+
sEntry.set(key, value);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
448
590
|
// ── Observation ──────────────────────────────────────────────────────────
|
|
449
591
|
|
|
450
592
|
/**
|
|
@@ -452,7 +594,7 @@ export class IdentityDocProvider extends EventEmitter {
|
|
|
452
594
|
* Returns an unsubscribe function.
|
|
453
595
|
*/
|
|
454
596
|
observe(
|
|
455
|
-
mapName: "profile" | "servers" | "plugins" | "preferences",
|
|
597
|
+
mapName: "profile" | "servers" | "plugins" | "preferences" | "devices",
|
|
456
598
|
callback: (events: Y.YEvent<any>[], transaction: Y.Transaction) => void,
|
|
457
599
|
): () => void {
|
|
458
600
|
const map = this.document.getMap(mapName);
|
package/src/index.ts
CHANGED
|
@@ -342,19 +342,25 @@ export class AbracadabraWebRTC extends EventEmitter {
|
|
|
342
342
|
if (!pc) return;
|
|
343
343
|
|
|
344
344
|
const channelName = "custom";
|
|
345
|
-
|
|
346
|
-
if (
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
// Wait for open before sending.
|
|
350
|
-
channel.onopen = () => {
|
|
351
|
-
const data = new TextEncoder().encode(payload);
|
|
352
|
-
pc.router.send(channelName, data);
|
|
353
|
-
};
|
|
345
|
+
const channel = pc.router.getChannel(channelName);
|
|
346
|
+
if (channel && channel.readyState === "open") {
|
|
347
|
+
const data = new TextEncoder().encode(payload);
|
|
348
|
+
pc.router.send(channelName, data).catch(() => {});
|
|
354
349
|
return;
|
|
355
350
|
}
|
|
356
|
-
|
|
357
|
-
|
|
351
|
+
|
|
352
|
+
// Channel not open yet — queue via the router's channelOpen event.
|
|
353
|
+
// Do NOT create a new channel here. The "custom" channel must be
|
|
354
|
+
// created during initiateConnection to be included in the SDP offer.
|
|
355
|
+
// If it doesn't exist yet, the peer hasn't finished negotiation.
|
|
356
|
+
const onOpen = ({ name }: { name: string }) => {
|
|
357
|
+
if (name === channelName) {
|
|
358
|
+
pc.router.off("channelOpen", onOpen);
|
|
359
|
+
const data = new TextEncoder().encode(payload);
|
|
360
|
+
pc.router.send(channelName, data).catch(() => {});
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
pc.router.on("channelOpen", onOpen);
|
|
358
364
|
}
|
|
359
365
|
|
|
360
366
|
/**
|
|
@@ -475,8 +481,12 @@ export class AbracadabraWebRTC extends EventEmitter {
|
|
|
475
481
|
// Listen for key-exchange messages on the router.
|
|
476
482
|
pc.router.on("channelMessage", async ({ name, data }: { name: string; data: any }) => {
|
|
477
483
|
if (name === KEY_EXCHANGE_CHANNEL) {
|
|
478
|
-
|
|
479
|
-
|
|
484
|
+
try {
|
|
485
|
+
const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
|
486
|
+
await e2ee.handleKeyExchange(buf);
|
|
487
|
+
} catch (err) {
|
|
488
|
+
this.emit("e2eeFailed", { peerId, error: err });
|
|
489
|
+
}
|
|
480
490
|
}
|
|
481
491
|
});
|
|
482
492
|
|
|
@@ -556,10 +566,16 @@ export class AbracadabraWebRTC extends EventEmitter {
|
|
|
556
566
|
enableFileTransfer: this.config.enableFileTransfer,
|
|
557
567
|
});
|
|
558
568
|
|
|
569
|
+
// Always create the "custom" channel for application messages
|
|
570
|
+
// (e.g. device pairing). Must be in the initial offer so the
|
|
571
|
+
// remote peer receives it via ondatachannel.
|
|
572
|
+
pc.router.createChannel("custom", { ordered: true });
|
|
573
|
+
|
|
559
574
|
try {
|
|
560
575
|
const sdp = await pc.createOffer();
|
|
561
576
|
this.signaling?.sendOffer(peerId, sdp);
|
|
562
|
-
} catch {
|
|
577
|
+
} catch (err) {
|
|
578
|
+
this.emit("error", { type: "offer-failed", peerId, error: err });
|
|
563
579
|
this.removePeer(peerId);
|
|
564
580
|
}
|
|
565
581
|
}
|
|
@@ -581,7 +597,8 @@ export class AbracadabraWebRTC extends EventEmitter {
|
|
|
581
597
|
try {
|
|
582
598
|
const answerSdp = await pc.setRemoteOffer(sdp);
|
|
583
599
|
this.signaling?.sendAnswer(from, answerSdp);
|
|
584
|
-
} catch {
|
|
600
|
+
} catch (err) {
|
|
601
|
+
this.emit("error", { type: "answer-failed", peerId: from, error: err });
|
|
585
602
|
this.removePeer(from);
|
|
586
603
|
}
|
|
587
604
|
}
|
|
@@ -378,6 +378,18 @@ export class DevicePairingChannel extends EventEmitter {
|
|
|
378
378
|
},
|
|
379
379
|
);
|
|
380
380
|
|
|
381
|
+
this.webrtc.on("e2eeFailed", ({ peerId, error }: { peerId: string; error: any }) => {
|
|
382
|
+
if (!this._destroyed) {
|
|
383
|
+
this.emit("error", new Error(`E2EE failed: ${error?.message ?? error}`));
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
this.webrtc.on("error", (err: any) => {
|
|
388
|
+
if (!this._destroyed) {
|
|
389
|
+
this.emit("error", new Error(`WebRTC: ${err?.type ?? err?.message ?? "unknown"}`));
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
381
393
|
this.webrtc.on("peerLeft", () => {
|
|
382
394
|
if (!this._destroyed) {
|
|
383
395
|
this.emit("error", new Error("Peer disconnected"));
|