androdex 1.1.3
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/LICENSE +15 -0
- package/README.md +84 -0
- package/bin/androdex.js +8 -0
- package/bin/cli.js +105 -0
- package/package.json +40 -0
- package/src/bridge.js +363 -0
- package/src/codex-desktop-launcher.js +93 -0
- package/src/codex-desktop-refresher.js +723 -0
- package/src/codex-transport.js +218 -0
- package/src/daemon-control.js +191 -0
- package/src/daemon-runtime.js +135 -0
- package/src/daemon-store.js +92 -0
- package/src/git-handler.js +672 -0
- package/src/host-runtime.js +554 -0
- package/src/index.js +38 -0
- package/src/qr.js +26 -0
- package/src/rollout-watch.js +308 -0
- package/src/scripts/codex-refresh.applescript +51 -0
- package/src/secure-device-state.js +257 -0
- package/src/secure-transport.js +737 -0
- package/src/session-state.js +60 -0
- package/src/workspace-handler.js +464 -0
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
// FILE: secure-transport.js
|
|
2
|
+
// Purpose: Owns the bridge-side E2EE handshake, envelope crypto, and reconnect catch-up buffer.
|
|
3
|
+
// Layer: CLI helper
|
|
4
|
+
// Exports: createBridgeSecureTransport, SECURE_PROTOCOL_VERSION, PAIRING_QR_VERSION
|
|
5
|
+
// Depends on: crypto, ./secure-device-state
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
createCipheriv,
|
|
9
|
+
createDecipheriv,
|
|
10
|
+
createHash,
|
|
11
|
+
createPrivateKey,
|
|
12
|
+
createPublicKey,
|
|
13
|
+
diffieHellman,
|
|
14
|
+
generateKeyPairSync,
|
|
15
|
+
hkdfSync,
|
|
16
|
+
randomBytes,
|
|
17
|
+
randomUUID,
|
|
18
|
+
sign,
|
|
19
|
+
verify,
|
|
20
|
+
} = require("crypto");
|
|
21
|
+
const {
|
|
22
|
+
getTrustedPhonePublicKey,
|
|
23
|
+
rememberTrustedPhone,
|
|
24
|
+
} = require("./secure-device-state");
|
|
25
|
+
|
|
26
|
+
const PAIRING_QR_VERSION = 3;
|
|
27
|
+
const SECURE_PROTOCOL_VERSION = 1;
|
|
28
|
+
const HANDSHAKE_TAG = "androdex-e2ee-v1";
|
|
29
|
+
const HANDSHAKE_MODE_QR_BOOTSTRAP = "qr_bootstrap";
|
|
30
|
+
const HANDSHAKE_MODE_TRUSTED_RECONNECT = "trusted_reconnect";
|
|
31
|
+
const SECURE_SENDER_MAC = "mac";
|
|
32
|
+
const SECURE_SENDER_IPHONE = "iphone";
|
|
33
|
+
const MAX_PAIRING_AGE_MS = 5 * 60 * 1000;
|
|
34
|
+
const MAX_BRIDGE_OUTBOUND_MESSAGES = 500;
|
|
35
|
+
const MAX_BRIDGE_OUTBOUND_BYTES = 10 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
function createBridgeSecureTransport({ hostId, sessionId, relayUrl, deviceState } = {}) {
|
|
38
|
+
const stableHostId = normalizeNonEmptyString(hostId)
|
|
39
|
+
|| normalizeNonEmptyString(sessionId)
|
|
40
|
+
|| normalizeNonEmptyString(deviceState?.hostId)
|
|
41
|
+
|| randomUUID();
|
|
42
|
+
let currentDeviceState = deviceState;
|
|
43
|
+
let pendingHandshake = null;
|
|
44
|
+
let activeSession = null;
|
|
45
|
+
let liveSendWireMessage = null;
|
|
46
|
+
let currentPairingExpiresAt = Date.now() + MAX_PAIRING_AGE_MS;
|
|
47
|
+
let currentBootstrapToken = "";
|
|
48
|
+
let nextKeyEpoch = 1;
|
|
49
|
+
let nextBridgeOutboundSeq = 1;
|
|
50
|
+
let outboundBufferBytes = 0;
|
|
51
|
+
const outboundBuffer = [];
|
|
52
|
+
|
|
53
|
+
function createPairingPayload() {
|
|
54
|
+
currentPairingExpiresAt = Date.now() + MAX_PAIRING_AGE_MS;
|
|
55
|
+
currentBootstrapToken = randomUUID();
|
|
56
|
+
return {
|
|
57
|
+
v: PAIRING_QR_VERSION,
|
|
58
|
+
relay: relayUrl,
|
|
59
|
+
hostId: stableHostId,
|
|
60
|
+
macDeviceId: currentDeviceState.macDeviceId,
|
|
61
|
+
macIdentityPublicKey: currentDeviceState.macIdentityPublicKey,
|
|
62
|
+
bootstrapToken: currentBootstrapToken,
|
|
63
|
+
expiresAt: currentPairingExpiresAt,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function handleIncomingWireMessage(rawMessage, { sendControlMessage, onApplicationMessage }) {
|
|
68
|
+
const parsed = safeParseJSON(rawMessage);
|
|
69
|
+
if (!parsed || typeof parsed !== "object") {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const kind = normalizeNonEmptyString(parsed.kind);
|
|
74
|
+
if (!kind) {
|
|
75
|
+
if (parsed.method || parsed.id != null) {
|
|
76
|
+
sendControlMessage(createSecureError({
|
|
77
|
+
code: "update_required",
|
|
78
|
+
message: "This bridge requires the latest compatible Androdex mobile app for secure pairing.",
|
|
79
|
+
}));
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
switch (kind) {
|
|
86
|
+
case "clientHello":
|
|
87
|
+
handleClientHello(parsed, sendControlMessage);
|
|
88
|
+
return true;
|
|
89
|
+
case "clientAuth":
|
|
90
|
+
handleClientAuth(parsed, sendControlMessage);
|
|
91
|
+
return true;
|
|
92
|
+
case "resumeState":
|
|
93
|
+
handleResumeState(parsed);
|
|
94
|
+
return true;
|
|
95
|
+
case "encryptedEnvelope":
|
|
96
|
+
return handleEncryptedEnvelope(parsed, sendControlMessage, onApplicationMessage);
|
|
97
|
+
default:
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function queueOutboundApplicationMessage(payloadText, sendWireMessage) {
|
|
103
|
+
const normalizedPayload = normalizeNonEmptyString(payloadText);
|
|
104
|
+
if (!normalizedPayload) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const bufferEntry = {
|
|
109
|
+
bridgeOutboundSeq: nextBridgeOutboundSeq,
|
|
110
|
+
payloadText: normalizedPayload,
|
|
111
|
+
sizeBytes: Buffer.byteLength(normalizedPayload, "utf8"),
|
|
112
|
+
};
|
|
113
|
+
nextBridgeOutboundSeq += 1;
|
|
114
|
+
outboundBuffer.push(bufferEntry);
|
|
115
|
+
outboundBufferBytes += bufferEntry.sizeBytes;
|
|
116
|
+
trimOutboundBuffer();
|
|
117
|
+
|
|
118
|
+
if (activeSession?.isResumed) {
|
|
119
|
+
sendBufferedEntry(bufferEntry, sendWireMessage);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isSecureChannelReady() {
|
|
124
|
+
return Boolean(activeSession?.isResumed);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Rejects QR bootstraps from a second mobile client unless it is the already-trusted device.
|
|
128
|
+
function hasConflictingTrustedPhone(phoneDeviceId, phoneIdentityPublicKey) {
|
|
129
|
+
const trustedPhones = currentDeviceState.trustedPhones || {};
|
|
130
|
+
return Object.entries(trustedPhones).some(([trustedDeviceId, trustedPublicKey]) => (
|
|
131
|
+
trustedDeviceId !== phoneDeviceId || trustedPublicKey !== phoneIdentityPublicKey
|
|
132
|
+
));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function handleClientHello(message, sendControlMessage) {
|
|
136
|
+
const protocolVersion = Number(message.protocolVersion);
|
|
137
|
+
const incomingSessionId = normalizeNonEmptyString(message.hostId || message.sessionId);
|
|
138
|
+
const handshakeMode = normalizeNonEmptyString(message.handshakeMode);
|
|
139
|
+
const phoneDeviceId = normalizeNonEmptyString(message.phoneDeviceId);
|
|
140
|
+
const phoneIdentityPublicKey = normalizeNonEmptyString(message.phoneIdentityPublicKey);
|
|
141
|
+
const phoneEphemeralPublicKey = normalizeNonEmptyString(message.phoneEphemeralPublicKey);
|
|
142
|
+
const clientNonceBase64 = normalizeNonEmptyString(message.clientNonce);
|
|
143
|
+
const bootstrapToken = normalizeNonEmptyString(message.bootstrapToken);
|
|
144
|
+
|
|
145
|
+
if (protocolVersion !== SECURE_PROTOCOL_VERSION || incomingSessionId !== stableHostId) {
|
|
146
|
+
sendControlMessage(createSecureError({
|
|
147
|
+
code: "update_required",
|
|
148
|
+
message: "The bridge and mobile client are not using the same secure transport version.",
|
|
149
|
+
}));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!phoneDeviceId || !phoneIdentityPublicKey || !phoneEphemeralPublicKey || !clientNonceBase64) {
|
|
154
|
+
sendControlMessage(createSecureError({
|
|
155
|
+
code: "invalid_client_hello",
|
|
156
|
+
message: "The mobile-client handshake is missing required secure fields.",
|
|
157
|
+
}));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (handshakeMode !== HANDSHAKE_MODE_QR_BOOTSTRAP && handshakeMode !== HANDSHAKE_MODE_TRUSTED_RECONNECT) {
|
|
162
|
+
sendControlMessage(createSecureError({
|
|
163
|
+
code: "invalid_handshake_mode",
|
|
164
|
+
message: "The mobile client requested an unknown secure pairing mode.",
|
|
165
|
+
}));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (handshakeMode === HANDSHAKE_MODE_QR_BOOTSTRAP && Date.now() > currentPairingExpiresAt) {
|
|
170
|
+
sendControlMessage(createSecureError({
|
|
171
|
+
code: "pairing_expired",
|
|
172
|
+
message: "The pairing QR code has expired. Generate a new QR code from the bridge.",
|
|
173
|
+
}));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (handshakeMode === HANDSHAKE_MODE_QR_BOOTSTRAP && bootstrapToken !== currentBootstrapToken) {
|
|
177
|
+
sendControlMessage(createSecureError({
|
|
178
|
+
code: "pairing_expired",
|
|
179
|
+
message: "The pairing token is no longer valid. Generate a new QR code from the daemon.",
|
|
180
|
+
}));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const trustedPhonePublicKey = getTrustedPhonePublicKey(currentDeviceState, phoneDeviceId);
|
|
185
|
+
if (handshakeMode === HANDSHAKE_MODE_QR_BOOTSTRAP && hasConflictingTrustedPhone(phoneDeviceId, phoneIdentityPublicKey)) {
|
|
186
|
+
sendControlMessage(createSecureError({
|
|
187
|
+
code: "phone_replacement_required",
|
|
188
|
+
message: "This host is already paired with another mobile client. Reset pairing on the host before pairing a new device.",
|
|
189
|
+
}));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (handshakeMode === HANDSHAKE_MODE_TRUSTED_RECONNECT) {
|
|
194
|
+
if (!trustedPhonePublicKey) {
|
|
195
|
+
sendControlMessage(createSecureError({
|
|
196
|
+
code: "phone_not_trusted",
|
|
197
|
+
message: "This mobile client is not trusted by the current bridge session. Scan a fresh QR code to pair again.",
|
|
198
|
+
}));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (trustedPhonePublicKey !== phoneIdentityPublicKey) {
|
|
202
|
+
sendControlMessage(createSecureError({
|
|
203
|
+
code: "phone_identity_changed",
|
|
204
|
+
message: "The trusted mobile-client identity does not match this reconnect attempt.",
|
|
205
|
+
}));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const clientNonce = base64ToBuffer(clientNonceBase64);
|
|
211
|
+
if (!clientNonce || clientNonce.length === 0) {
|
|
212
|
+
sendControlMessage(createSecureError({
|
|
213
|
+
code: "invalid_client_nonce",
|
|
214
|
+
message: "The mobile-client secure nonce could not be decoded.",
|
|
215
|
+
}));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const ephemeral = generateKeyPairSync("x25519");
|
|
220
|
+
const privateJwk = ephemeral.privateKey.export({ format: "jwk" });
|
|
221
|
+
const publicJwk = ephemeral.publicKey.export({ format: "jwk" });
|
|
222
|
+
const serverNonce = randomBytes(32);
|
|
223
|
+
const keyEpoch = nextKeyEpoch;
|
|
224
|
+
const expiresAtForTranscript = handshakeMode === HANDSHAKE_MODE_QR_BOOTSTRAP
|
|
225
|
+
? currentPairingExpiresAt
|
|
226
|
+
: 0;
|
|
227
|
+
const transcriptBytes = buildTranscriptBytes({
|
|
228
|
+
sessionId: stableHostId,
|
|
229
|
+
protocolVersion,
|
|
230
|
+
handshakeMode,
|
|
231
|
+
keyEpoch,
|
|
232
|
+
macDeviceId: currentDeviceState.macDeviceId,
|
|
233
|
+
phoneDeviceId,
|
|
234
|
+
macIdentityPublicKey: currentDeviceState.macIdentityPublicKey,
|
|
235
|
+
phoneIdentityPublicKey,
|
|
236
|
+
macEphemeralPublicKey: base64UrlToBase64(publicJwk.x),
|
|
237
|
+
phoneEphemeralPublicKey,
|
|
238
|
+
clientNonce,
|
|
239
|
+
serverNonce,
|
|
240
|
+
expiresAtForTranscript,
|
|
241
|
+
});
|
|
242
|
+
const macSignature = signTranscript(
|
|
243
|
+
currentDeviceState.macIdentityPrivateKey,
|
|
244
|
+
currentDeviceState.macIdentityPublicKey,
|
|
245
|
+
transcriptBytes
|
|
246
|
+
);
|
|
247
|
+
debugSecureLog(
|
|
248
|
+
`serverHello mode=${handshakeMode} session=${shortId(stableHostId)} keyEpoch=${keyEpoch} `
|
|
249
|
+
+ `mac=${shortId(currentDeviceState.macDeviceId)} phone=${shortId(phoneDeviceId)} `
|
|
250
|
+
+ `macKey=${shortFingerprint(currentDeviceState.macIdentityPublicKey)} `
|
|
251
|
+
+ `phoneKey=${shortFingerprint(phoneIdentityPublicKey)} `
|
|
252
|
+
+ `transcript=${transcriptDigest(transcriptBytes)}`
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
pendingHandshake = {
|
|
256
|
+
sessionId: stableHostId,
|
|
257
|
+
handshakeMode,
|
|
258
|
+
keyEpoch,
|
|
259
|
+
phoneDeviceId,
|
|
260
|
+
phoneIdentityPublicKey,
|
|
261
|
+
phoneEphemeralPublicKey,
|
|
262
|
+
macEphemeralPrivateKey: base64UrlToBase64(privateJwk.d),
|
|
263
|
+
macEphemeralPublicKey: base64UrlToBase64(publicJwk.x),
|
|
264
|
+
transcriptBytes,
|
|
265
|
+
expiresAtForTranscript,
|
|
266
|
+
};
|
|
267
|
+
activeSession = null;
|
|
268
|
+
|
|
269
|
+
sendControlMessage({
|
|
270
|
+
kind: "serverHello",
|
|
271
|
+
protocolVersion: SECURE_PROTOCOL_VERSION,
|
|
272
|
+
sessionId: stableHostId,
|
|
273
|
+
hostId: stableHostId,
|
|
274
|
+
handshakeMode,
|
|
275
|
+
macDeviceId: currentDeviceState.macDeviceId,
|
|
276
|
+
macIdentityPublicKey: currentDeviceState.macIdentityPublicKey,
|
|
277
|
+
macEphemeralPublicKey: pendingHandshake.macEphemeralPublicKey,
|
|
278
|
+
serverNonce: serverNonce.toString("base64"),
|
|
279
|
+
keyEpoch,
|
|
280
|
+
expiresAtForTranscript,
|
|
281
|
+
macSignature,
|
|
282
|
+
clientNonce: clientNonceBase64,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function handleClientAuth(message, sendControlMessage) {
|
|
287
|
+
if (!pendingHandshake) {
|
|
288
|
+
sendControlMessage(createSecureError({
|
|
289
|
+
code: "unexpected_client_auth",
|
|
290
|
+
message: "The bridge did not have a pending secure handshake to finalize.",
|
|
291
|
+
}));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const incomingSessionId = normalizeNonEmptyString(message.hostId || message.sessionId);
|
|
296
|
+
const phoneDeviceId = normalizeNonEmptyString(message.phoneDeviceId);
|
|
297
|
+
const keyEpoch = Number(message.keyEpoch);
|
|
298
|
+
const phoneSignature = normalizeNonEmptyString(message.phoneSignature);
|
|
299
|
+
if (
|
|
300
|
+
incomingSessionId !== pendingHandshake.sessionId
|
|
301
|
+
|| phoneDeviceId !== pendingHandshake.phoneDeviceId
|
|
302
|
+
|| keyEpoch !== pendingHandshake.keyEpoch
|
|
303
|
+
|| !phoneSignature
|
|
304
|
+
) {
|
|
305
|
+
pendingHandshake = null;
|
|
306
|
+
sendControlMessage(createSecureError({
|
|
307
|
+
code: "invalid_client_auth",
|
|
308
|
+
message: "The secure client authentication payload was invalid.",
|
|
309
|
+
}));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const clientAuthTranscript = Buffer.concat([
|
|
314
|
+
pendingHandshake.transcriptBytes,
|
|
315
|
+
encodeLengthPrefixedUTF8("client-auth"),
|
|
316
|
+
]);
|
|
317
|
+
const phoneVerified = verifyTranscript(
|
|
318
|
+
pendingHandshake.phoneIdentityPublicKey,
|
|
319
|
+
clientAuthTranscript,
|
|
320
|
+
phoneSignature
|
|
321
|
+
);
|
|
322
|
+
if (!phoneVerified) {
|
|
323
|
+
pendingHandshake = null;
|
|
324
|
+
sendControlMessage(createSecureError({
|
|
325
|
+
code: "invalid_phone_signature",
|
|
326
|
+
message: "The mobile-client secure signature could not be verified.",
|
|
327
|
+
}));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const sharedSecret = diffieHellman({
|
|
332
|
+
privateKey: createPrivateKey({
|
|
333
|
+
key: {
|
|
334
|
+
crv: "X25519",
|
|
335
|
+
d: base64ToBase64Url(pendingHandshake.macEphemeralPrivateKey),
|
|
336
|
+
kty: "OKP",
|
|
337
|
+
x: base64ToBase64Url(pendingHandshake.macEphemeralPublicKey),
|
|
338
|
+
},
|
|
339
|
+
format: "jwk",
|
|
340
|
+
}),
|
|
341
|
+
publicKey: createPublicKey({
|
|
342
|
+
key: {
|
|
343
|
+
crv: "X25519",
|
|
344
|
+
kty: "OKP",
|
|
345
|
+
x: base64ToBase64Url(pendingHandshake.phoneEphemeralPublicKey),
|
|
346
|
+
},
|
|
347
|
+
format: "jwk",
|
|
348
|
+
}),
|
|
349
|
+
});
|
|
350
|
+
const salt = createHash("sha256").update(pendingHandshake.transcriptBytes).digest();
|
|
351
|
+
const infoPrefix = [
|
|
352
|
+
HANDSHAKE_TAG,
|
|
353
|
+
pendingHandshake.sessionId,
|
|
354
|
+
currentDeviceState.macDeviceId,
|
|
355
|
+
pendingHandshake.phoneDeviceId,
|
|
356
|
+
String(pendingHandshake.keyEpoch),
|
|
357
|
+
].join("|");
|
|
358
|
+
|
|
359
|
+
activeSession = {
|
|
360
|
+
sessionId: pendingHandshake.sessionId,
|
|
361
|
+
keyEpoch: pendingHandshake.keyEpoch,
|
|
362
|
+
phoneDeviceId: pendingHandshake.phoneDeviceId,
|
|
363
|
+
phoneIdentityPublicKey: pendingHandshake.phoneIdentityPublicKey,
|
|
364
|
+
phoneToMacKey: deriveAesKey(sharedSecret, salt, `${infoPrefix}|phoneToMac`),
|
|
365
|
+
macToPhoneKey: deriveAesKey(sharedSecret, salt, `${infoPrefix}|macToPhone`),
|
|
366
|
+
lastInboundCounter: -1,
|
|
367
|
+
nextOutboundCounter: 0,
|
|
368
|
+
isResumed: false,
|
|
369
|
+
sendWireMessage: liveSendWireMessage,
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
nextKeyEpoch = pendingHandshake.keyEpoch + 1;
|
|
373
|
+
if (
|
|
374
|
+
pendingHandshake.handshakeMode === HANDSHAKE_MODE_QR_BOOTSTRAP
|
|
375
|
+
|| getTrustedPhonePublicKey(currentDeviceState, pendingHandshake.phoneDeviceId)
|
|
376
|
+
) {
|
|
377
|
+
currentDeviceState = rememberTrustedPhone(
|
|
378
|
+
currentDeviceState,
|
|
379
|
+
pendingHandshake.phoneDeviceId,
|
|
380
|
+
pendingHandshake.phoneIdentityPublicKey
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
if (pendingHandshake.handshakeMode === HANDSHAKE_MODE_QR_BOOTSTRAP) {
|
|
384
|
+
resetOutboundReplayState();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
pendingHandshake = null;
|
|
388
|
+
sendControlMessage({
|
|
389
|
+
kind: "secureReady",
|
|
390
|
+
sessionId: stableHostId,
|
|
391
|
+
hostId: stableHostId,
|
|
392
|
+
keyEpoch: activeSession.keyEpoch,
|
|
393
|
+
macDeviceId: currentDeviceState.macDeviceId,
|
|
394
|
+
});
|
|
395
|
+
currentBootstrapToken = "";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function handleResumeState(message) {
|
|
399
|
+
if (!activeSession) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const incomingSessionId = normalizeNonEmptyString(message.hostId || message.sessionId);
|
|
404
|
+
const keyEpoch = Number(message.keyEpoch);
|
|
405
|
+
if (incomingSessionId !== stableHostId || keyEpoch !== activeSession.keyEpoch) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const lastAppliedBridgeOutboundSeq = Number(message.lastAppliedBridgeOutboundSeq) || 0;
|
|
410
|
+
const missingEntries = outboundBuffer.filter(
|
|
411
|
+
(entry) => entry.bridgeOutboundSeq > lastAppliedBridgeOutboundSeq
|
|
412
|
+
);
|
|
413
|
+
activeSession.isResumed = true;
|
|
414
|
+
for (const entry of missingEntries) {
|
|
415
|
+
sendBufferedEntry(entry, messageText => {
|
|
416
|
+
if (typeof activeSession.sendWireMessage === "function") {
|
|
417
|
+
activeSession.sendWireMessage(messageText);
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function handleEncryptedEnvelope(message, sendControlMessage, onApplicationMessage) {
|
|
424
|
+
if (!activeSession) {
|
|
425
|
+
sendControlMessage(createSecureError({
|
|
426
|
+
code: "secure_channel_unavailable",
|
|
427
|
+
message: "The secure channel is not ready yet on the bridge.",
|
|
428
|
+
}));
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const incomingSessionId = normalizeNonEmptyString(message.hostId || message.sessionId);
|
|
433
|
+
const keyEpoch = Number(message.keyEpoch);
|
|
434
|
+
const sender = normalizeNonEmptyString(message.sender);
|
|
435
|
+
const counter = Number(message.counter);
|
|
436
|
+
if (
|
|
437
|
+
incomingSessionId !== stableHostId
|
|
438
|
+
|| keyEpoch !== activeSession.keyEpoch
|
|
439
|
+
|| sender !== SECURE_SENDER_IPHONE
|
|
440
|
+
|| !Number.isInteger(counter)
|
|
441
|
+
|| counter <= activeSession.lastInboundCounter
|
|
442
|
+
) {
|
|
443
|
+
sendControlMessage(createSecureError({
|
|
444
|
+
code: "invalid_envelope",
|
|
445
|
+
message: "The bridge rejected an invalid or replayed secure envelope.",
|
|
446
|
+
}));
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const plaintextBuffer = decryptEnvelopeBuffer(message, activeSession.phoneToMacKey, SECURE_SENDER_IPHONE, counter);
|
|
451
|
+
if (!plaintextBuffer) {
|
|
452
|
+
sendControlMessage(createSecureError({
|
|
453
|
+
code: "decrypt_failed",
|
|
454
|
+
message: "The bridge could not decrypt the secure payload from the mobile client.",
|
|
455
|
+
}));
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
activeSession.lastInboundCounter = counter;
|
|
460
|
+
const payloadObject = safeParseJSON(plaintextBuffer.toString("utf8"));
|
|
461
|
+
const payloadText = normalizeNonEmptyString(payloadObject?.payloadText);
|
|
462
|
+
if (!payloadText) {
|
|
463
|
+
sendControlMessage(createSecureError({
|
|
464
|
+
code: "invalid_payload",
|
|
465
|
+
message: "The secure payload did not contain a usable application message.",
|
|
466
|
+
}));
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
onApplicationMessage(payloadText);
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function bindLiveSendWireMessage(sendWireMessage) {
|
|
475
|
+
liveSendWireMessage = sendWireMessage;
|
|
476
|
+
if (activeSession) {
|
|
477
|
+
activeSession.sendWireMessage = sendWireMessage;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function trimOutboundBuffer() {
|
|
482
|
+
while (
|
|
483
|
+
outboundBuffer.length > MAX_BRIDGE_OUTBOUND_MESSAGES
|
|
484
|
+
|| outboundBufferBytes > MAX_BRIDGE_OUTBOUND_BYTES
|
|
485
|
+
) {
|
|
486
|
+
const removed = outboundBuffer.shift();
|
|
487
|
+
if (!removed) {
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
outboundBufferBytes = Math.max(0, outboundBufferBytes - removed.sizeBytes);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Starts each fresh QR bootstrap with a clean catch-up window for the single trusted phone.
|
|
495
|
+
function resetOutboundReplayState() {
|
|
496
|
+
outboundBuffer.length = 0;
|
|
497
|
+
outboundBufferBytes = 0;
|
|
498
|
+
nextBridgeOutboundSeq = 1;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function sendBufferedEntry(entry, sendWireMessage) {
|
|
502
|
+
if (!activeSession?.isResumed) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const envelope = encryptEnvelopePayload(
|
|
507
|
+
{
|
|
508
|
+
bridgeOutboundSeq: entry.bridgeOutboundSeq,
|
|
509
|
+
payloadText: entry.payloadText,
|
|
510
|
+
},
|
|
511
|
+
activeSession.macToPhoneKey,
|
|
512
|
+
SECURE_SENDER_MAC,
|
|
513
|
+
activeSession.nextOutboundCounter,
|
|
514
|
+
stableHostId,
|
|
515
|
+
activeSession.keyEpoch
|
|
516
|
+
);
|
|
517
|
+
activeSession.nextOutboundCounter += 1;
|
|
518
|
+
sendWireMessage(JSON.stringify(envelope));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
PAIRING_QR_VERSION,
|
|
523
|
+
SECURE_PROTOCOL_VERSION,
|
|
524
|
+
bindLiveSendWireMessage,
|
|
525
|
+
createPairingPayload,
|
|
526
|
+
getCurrentDeviceState() {
|
|
527
|
+
return currentDeviceState;
|
|
528
|
+
},
|
|
529
|
+
handleIncomingWireMessage,
|
|
530
|
+
isSecureChannelReady,
|
|
531
|
+
queueOutboundApplicationMessage,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function debugSecureLog(message) {
|
|
536
|
+
console.log(`[androdex][secure] ${message}`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function shortId(value) {
|
|
540
|
+
const normalized = normalizeNonEmptyString(value);
|
|
541
|
+
return normalized ? normalized.slice(0, 8) : "none";
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function shortFingerprint(publicKeyBase64) {
|
|
545
|
+
const bytes = base64ToBuffer(publicKeyBase64);
|
|
546
|
+
if (!bytes || bytes.length === 0) {
|
|
547
|
+
return "invalid";
|
|
548
|
+
}
|
|
549
|
+
return createHash("sha256").update(bytes).digest("hex").slice(0, 12);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function transcriptDigest(transcriptBytes) {
|
|
553
|
+
return createHash("sha256").update(transcriptBytes).digest("hex").slice(0, 16);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function encryptEnvelopePayload(payloadObject, key, sender, counter, sessionId, keyEpoch) {
|
|
557
|
+
const nonce = nonceForDirection(sender, counter);
|
|
558
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
559
|
+
const ciphertext = Buffer.concat([
|
|
560
|
+
cipher.update(Buffer.from(JSON.stringify(payloadObject), "utf8")),
|
|
561
|
+
cipher.final(),
|
|
562
|
+
]);
|
|
563
|
+
const tag = cipher.getAuthTag();
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
kind: "encryptedEnvelope",
|
|
567
|
+
v: SECURE_PROTOCOL_VERSION,
|
|
568
|
+
sessionId,
|
|
569
|
+
keyEpoch,
|
|
570
|
+
sender,
|
|
571
|
+
counter,
|
|
572
|
+
ciphertext: ciphertext.toString("base64"),
|
|
573
|
+
tag: tag.toString("base64"),
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function decryptEnvelopeBuffer(envelope, key, sender, counter) {
|
|
578
|
+
try {
|
|
579
|
+
const nonce = nonceForDirection(sender, counter);
|
|
580
|
+
const decipher = createDecipheriv("aes-256-gcm", key, nonce);
|
|
581
|
+
decipher.setAuthTag(base64ToBuffer(envelope.tag));
|
|
582
|
+
return Buffer.concat([
|
|
583
|
+
decipher.update(base64ToBuffer(envelope.ciphertext)),
|
|
584
|
+
decipher.final(),
|
|
585
|
+
]);
|
|
586
|
+
} catch {
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function deriveAesKey(sharedSecret, salt, infoLabel) {
|
|
592
|
+
return Buffer.from(hkdfSync("sha256", sharedSecret, salt, Buffer.from(infoLabel, "utf8"), 32));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function signTranscript(privateKeyBase64, publicKeyBase64, transcriptBytes) {
|
|
596
|
+
const signature = sign(
|
|
597
|
+
null,
|
|
598
|
+
transcriptBytes,
|
|
599
|
+
createPrivateKey({
|
|
600
|
+
key: {
|
|
601
|
+
crv: "Ed25519",
|
|
602
|
+
d: base64ToBase64Url(privateKeyBase64),
|
|
603
|
+
kty: "OKP",
|
|
604
|
+
x: base64ToBase64Url(publicKeyBase64),
|
|
605
|
+
},
|
|
606
|
+
format: "jwk",
|
|
607
|
+
})
|
|
608
|
+
);
|
|
609
|
+
return signature.toString("base64");
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function verifyTranscript(publicKeyBase64, transcriptBytes, signatureBase64) {
|
|
613
|
+
try {
|
|
614
|
+
return verify(
|
|
615
|
+
null,
|
|
616
|
+
transcriptBytes,
|
|
617
|
+
createPublicKey({
|
|
618
|
+
key: {
|
|
619
|
+
crv: "Ed25519",
|
|
620
|
+
kty: "OKP",
|
|
621
|
+
x: base64ToBase64Url(publicKeyBase64),
|
|
622
|
+
},
|
|
623
|
+
format: "jwk",
|
|
624
|
+
}),
|
|
625
|
+
base64ToBuffer(signatureBase64)
|
|
626
|
+
);
|
|
627
|
+
} catch {
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function buildTranscriptBytes({
|
|
633
|
+
sessionId,
|
|
634
|
+
protocolVersion,
|
|
635
|
+
handshakeMode,
|
|
636
|
+
keyEpoch,
|
|
637
|
+
macDeviceId,
|
|
638
|
+
phoneDeviceId,
|
|
639
|
+
macIdentityPublicKey,
|
|
640
|
+
phoneIdentityPublicKey,
|
|
641
|
+
macEphemeralPublicKey,
|
|
642
|
+
phoneEphemeralPublicKey,
|
|
643
|
+
clientNonce,
|
|
644
|
+
serverNonce,
|
|
645
|
+
expiresAtForTranscript,
|
|
646
|
+
}) {
|
|
647
|
+
return Buffer.concat([
|
|
648
|
+
encodeLengthPrefixedUTF8(HANDSHAKE_TAG),
|
|
649
|
+
encodeLengthPrefixedUTF8(sessionId),
|
|
650
|
+
encodeLengthPrefixedUTF8(String(protocolVersion)),
|
|
651
|
+
encodeLengthPrefixedUTF8(handshakeMode),
|
|
652
|
+
encodeLengthPrefixedUTF8(String(keyEpoch)),
|
|
653
|
+
encodeLengthPrefixedUTF8(macDeviceId),
|
|
654
|
+
encodeLengthPrefixedUTF8(phoneDeviceId),
|
|
655
|
+
encodeLengthPrefixedBuffer(base64ToBuffer(macIdentityPublicKey)),
|
|
656
|
+
encodeLengthPrefixedBuffer(base64ToBuffer(phoneIdentityPublicKey)),
|
|
657
|
+
encodeLengthPrefixedBuffer(base64ToBuffer(macEphemeralPublicKey)),
|
|
658
|
+
encodeLengthPrefixedBuffer(base64ToBuffer(phoneEphemeralPublicKey)),
|
|
659
|
+
encodeLengthPrefixedBuffer(clientNonce),
|
|
660
|
+
encodeLengthPrefixedBuffer(serverNonce),
|
|
661
|
+
encodeLengthPrefixedUTF8(String(expiresAtForTranscript)),
|
|
662
|
+
]);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function encodeLengthPrefixedUTF8(value) {
|
|
666
|
+
return encodeLengthPrefixedBuffer(Buffer.from(String(value), "utf8"));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function encodeLengthPrefixedBuffer(buffer) {
|
|
670
|
+
const lengthBuffer = Buffer.allocUnsafe(4);
|
|
671
|
+
lengthBuffer.writeUInt32BE(buffer.length, 0);
|
|
672
|
+
return Buffer.concat([lengthBuffer, buffer]);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function nonceForDirection(sender, counter) {
|
|
676
|
+
const nonce = Buffer.alloc(12, 0);
|
|
677
|
+
nonce.writeUInt8(sender === SECURE_SENDER_MAC ? 1 : 2, 0);
|
|
678
|
+
let value = BigInt(counter);
|
|
679
|
+
for (let index = 11; index >= 1; index -= 1) {
|
|
680
|
+
nonce[index] = Number(value & 0xffn);
|
|
681
|
+
value >>= 8n;
|
|
682
|
+
}
|
|
683
|
+
return nonce;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function createSecureError({ code, message }) {
|
|
687
|
+
return {
|
|
688
|
+
kind: "secureError",
|
|
689
|
+
code,
|
|
690
|
+
message,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function normalizeNonEmptyString(value) {
|
|
695
|
+
if (typeof value !== "string") {
|
|
696
|
+
return "";
|
|
697
|
+
}
|
|
698
|
+
return value.trim();
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function safeParseJSON(value) {
|
|
702
|
+
if (typeof value !== "string") {
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
try {
|
|
707
|
+
return JSON.parse(value);
|
|
708
|
+
} catch {
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function base64ToBuffer(value) {
|
|
714
|
+
try {
|
|
715
|
+
return Buffer.from(value, "base64");
|
|
716
|
+
} catch {
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function base64UrlToBase64(value) {
|
|
722
|
+
const padded = `${value}${"=".repeat((4 - (value.length % 4 || 4)) % 4)}`;
|
|
723
|
+
return padded.replace(/-/g, "+").replace(/_/g, "/");
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function base64ToBase64Url(value) {
|
|
727
|
+
return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
module.exports = {
|
|
731
|
+
HANDSHAKE_MODE_QR_BOOTSTRAP,
|
|
732
|
+
HANDSHAKE_MODE_TRUSTED_RECONNECT,
|
|
733
|
+
PAIRING_QR_VERSION,
|
|
734
|
+
SECURE_PROTOCOL_VERSION,
|
|
735
|
+
createBridgeSecureTransport,
|
|
736
|
+
nonceForDirection,
|
|
737
|
+
};
|