@viewportai/daemon 0.1.0 → 0.2.0
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/cli/commands.d.ts +1 -0
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +1 -0
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli/daemon-lifecycle.d.ts +3 -0
- package/dist/cli/daemon-lifecycle.d.ts.map +1 -1
- package/dist/cli/daemon-lifecycle.js +11 -1
- package/dist/cli/daemon-lifecycle.js.map +1 -1
- package/dist/cli/daemon-settings.d.ts.map +1 -1
- package/dist/cli/daemon-settings.js +115 -3
- package/dist/cli/daemon-settings.js.map +1 -1
- package/dist/cli/lifecycle-commands.d.ts.map +1 -1
- package/dist/cli/lifecycle-commands.js +2 -0
- package/dist/cli/lifecycle-commands.js.map +1 -1
- package/dist/cli/remote-commands.d.ts +3 -0
- package/dist/cli/remote-commands.d.ts.map +1 -0
- package/dist/cli/remote-commands.js +236 -0
- package/dist/cli/remote-commands.js.map +1 -0
- package/dist/cli/setup-command.d.ts.map +1 -1
- package/dist/cli/setup-command.js +4 -1
- package/dist/cli/setup-command.js.map +1 -1
- package/dist/cli/supervisor-protocol.d.ts +12 -0
- package/dist/cli/supervisor-protocol.d.ts.map +1 -1
- package/dist/cli/supervisor.d.ts.map +1 -1
- package/dist/cli/supervisor.js +30 -0
- package/dist/cli/supervisor.js.map +1 -1
- package/dist/core/config-schema.d.ts +16 -0
- package/dist/core/config-schema.d.ts.map +1 -1
- package/dist/core/config-schema.js +12 -0
- package/dist/core/config-schema.js.map +1 -1
- package/dist/core/config.d.ts +23 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +46 -3
- package/dist/core/config.js.map +1 -1
- package/dist/core/session-state-file.d.ts.map +1 -1
- package/dist/core/session-state-file.js +3 -1
- package/dist/core/session-state-file.js.map +1 -1
- package/dist/core/types.d.ts +7 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/hooks/installers/claude.js +4 -1
- package/dist/hooks/installers/claude.js.map +1 -1
- package/dist/hooks/router.d.ts.map +1 -1
- package/dist/hooks/router.js +11 -0
- package/dist/hooks/router.js.map +1 -1
- package/dist/hooks/supervision.d.ts +2 -0
- package/dist/hooks/supervision.d.ts.map +1 -1
- package/dist/hooks/supervision.js +12 -0
- package/dist/hooks/supervision.js.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +14 -0
- package/dist/plugins/loader.js.map +1 -1
- package/dist/relay/bridge-backoff.d.ts +3 -0
- package/dist/relay/bridge-backoff.d.ts.map +1 -0
- package/dist/relay/bridge-backoff.js +10 -0
- package/dist/relay/bridge-backoff.js.map +1 -0
- package/dist/relay/bridge-constants.d.ts +12 -0
- package/dist/relay/bridge-constants.d.ts.map +1 -0
- package/dist/relay/bridge-constants.js +12 -0
- package/dist/relay/bridge-constants.js.map +1 -0
- package/dist/relay/bridge-crypto.d.ts +18 -0
- package/dist/relay/bridge-crypto.d.ts.map +1 -0
- package/dist/relay/bridge-crypto.js +63 -0
- package/dist/relay/bridge-crypto.js.map +1 -0
- package/dist/relay/bridge-errors.d.ts +6 -0
- package/dist/relay/bridge-errors.d.ts.map +1 -0
- package/dist/relay/bridge-errors.js +9 -0
- package/dist/relay/bridge-errors.js.map +1 -0
- package/dist/relay/bridge-jwt.d.ts +18 -0
- package/dist/relay/bridge-jwt.d.ts.map +1 -0
- package/dist/relay/bridge-jwt.js +130 -0
- package/dist/relay/bridge-jwt.js.map +1 -0
- package/dist/relay/bridge-key-exchange.d.ts +49 -0
- package/dist/relay/bridge-key-exchange.d.ts.map +1 -0
- package/dist/relay/bridge-key-exchange.js +234 -0
- package/dist/relay/bridge-key-exchange.js.map +1 -0
- package/dist/relay/bridge-network.d.ts +12 -0
- package/dist/relay/bridge-network.d.ts.map +1 -0
- package/dist/relay/bridge-network.js +90 -0
- package/dist/relay/bridge-network.js.map +1 -0
- package/dist/relay/bridge-noise-v3.d.ts +74 -0
- package/dist/relay/bridge-noise-v3.d.ts.map +1 -0
- package/dist/relay/bridge-noise-v3.js +403 -0
- package/dist/relay/bridge-noise-v3.js.map +1 -0
- package/dist/relay/daemon-relay-bridge.d.ts +93 -0
- package/dist/relay/daemon-relay-bridge.d.ts.map +1 -0
- package/dist/relay/daemon-relay-bridge.js +1005 -0
- package/dist/relay/daemon-relay-bridge.js.map +1 -0
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +9 -7
- package/dist/server/auth.js.map +1 -1
- package/dist/server/http-server.d.ts +6 -0
- package/dist/server/http-server.d.ts.map +1 -1
- package/dist/server/http-server.js +102 -15
- package/dist/server/http-server.js.map +1 -1
- package/dist/server/pairing-offers.d.ts +2 -1
- package/dist/server/pairing-offers.d.ts.map +1 -1
- package/dist/server/pairing-offers.js +438 -204
- package/dist/server/pairing-offers.js.map +1 -1
- package/dist/server/ring-buffer.d.ts +48 -7
- package/dist/server/ring-buffer.d.ts.map +1 -1
- package/dist/server/ring-buffer.js +387 -14
- package/dist/server/ring-buffer.js.map +1 -1
- package/dist/server/security.d.ts.map +1 -1
- package/dist/server/security.js +5 -1
- package/dist/server/security.js.map +1 -1
- package/dist/server/ws-command-handlers.d.ts.map +1 -1
- package/dist/server/ws-command-handlers.js +18 -6
- package/dist/server/ws-command-handlers.js.map +1 -1
- package/dist/server/ws-daemon-event-bridge.d.ts.map +1 -1
- package/dist/server/ws-daemon-event-bridge.js +14 -2
- package/dist/server/ws-daemon-event-bridge.js.map +1 -1
- package/dist/server/ws-server.d.ts.map +1 -1
- package/dist/server/ws-server.js +26 -3
- package/dist/server/ws-server.js.map +1 -1
- package/dist/startup-relay-security.d.ts +3 -0
- package/dist/startup-relay-security.d.ts.map +1 -0
- package/dist/startup-relay-security.js +61 -0
- package/dist/startup-relay-security.js.map +1 -0
- package/dist/startup-session-persistence.d.ts +7 -0
- package/dist/startup-session-persistence.d.ts.map +1 -0
- package/dist/startup-session-persistence.js +72 -0
- package/dist/startup-session-persistence.js.map +1 -0
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +115 -65
- package/dist/startup.js.map +1 -1
- package/dist/tracking/git-tracker.d.ts +4 -0
- package/dist/tracking/git-tracker.d.ts.map +1 -1
- package/dist/tracking/git-tracker.js +80 -15
- package/dist/tracking/git-tracker.js.map +1 -1
- package/docs/configuration.md +63 -5
- package/docs/relay-noise-conformance-vectors.json +41 -0
- package/docs/relay-noise-v3-conformance-vectors.json +50 -0
- package/docs/security.md +3 -2
- package/package.json +1 -1
|
@@ -4,6 +4,38 @@ import path from 'node:path';
|
|
|
4
4
|
import { configDir } from '../core/config.js';
|
|
5
5
|
const MAX_STORED_OFFERS = 200;
|
|
6
6
|
const MAX_FAILED_REDEEM_ATTEMPTS = 5;
|
|
7
|
+
const DEFAULT_MAX_PEER_BINDINGS = 2048;
|
|
8
|
+
const RELAY_PAIRING_INFO_PREFIX = 'viewport-relay-policyc-pair-v1';
|
|
9
|
+
const DEFAULT_PAIRING_AUDIT_MAX_BYTES = 1_048_576;
|
|
10
|
+
const PAIRING_SECRET_STORE_KEY_BYTES = 32;
|
|
11
|
+
let storeMutationLock = Promise.resolve();
|
|
12
|
+
let auditMutationLock = Promise.resolve();
|
|
13
|
+
let cachedSecretStoreKey = null;
|
|
14
|
+
let cachedSecretStoreKeyPath = null;
|
|
15
|
+
function withStoreMutationLock(operation) {
|
|
16
|
+
const run = storeMutationLock.then(operation, operation);
|
|
17
|
+
storeMutationLock = run.then(() => undefined, () => undefined);
|
|
18
|
+
return run;
|
|
19
|
+
}
|
|
20
|
+
function withAuditMutationLock(operation) {
|
|
21
|
+
const run = auditMutationLock.then(operation, operation);
|
|
22
|
+
auditMutationLock = run.then(() => undefined, () => undefined);
|
|
23
|
+
return run;
|
|
24
|
+
}
|
|
25
|
+
function parsePositiveInt(value, fallback) {
|
|
26
|
+
if (!value)
|
|
27
|
+
return fallback;
|
|
28
|
+
const parsed = Number.parseInt(value, 10);
|
|
29
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
30
|
+
return fallback;
|
|
31
|
+
return parsed;
|
|
32
|
+
}
|
|
33
|
+
function pairingAuditMaxBytes() {
|
|
34
|
+
return parsePositiveInt(process.env['VIEWPORT_PAIRING_AUDIT_MAX_BYTES'], DEFAULT_PAIRING_AUDIT_MAX_BYTES);
|
|
35
|
+
}
|
|
36
|
+
function pairingPeerBindingsMax() {
|
|
37
|
+
return parsePositiveInt(process.env['VIEWPORT_PAIRING_PEER_BINDINGS_MAX'], DEFAULT_MAX_PEER_BINDINGS);
|
|
38
|
+
}
|
|
7
39
|
function pairingStorePath() {
|
|
8
40
|
return path.join(configDir(), 'pairing-offers.json');
|
|
9
41
|
}
|
|
@@ -22,6 +54,37 @@ function daemonIdentityPath() {
|
|
|
22
54
|
function peerBindingPath() {
|
|
23
55
|
return path.join(configDir(), 'pairing-peers.json');
|
|
24
56
|
}
|
|
57
|
+
function pairingSecretStoreKeyPath() {
|
|
58
|
+
return path.join(configDir(), 'pairing-secret-store.key');
|
|
59
|
+
}
|
|
60
|
+
async function getOrCreateSecretStoreKey() {
|
|
61
|
+
const keyPath = pairingSecretStoreKeyPath();
|
|
62
|
+
if (cachedSecretStoreKey &&
|
|
63
|
+
cachedSecretStoreKey.length === PAIRING_SECRET_STORE_KEY_BYTES &&
|
|
64
|
+
cachedSecretStoreKeyPath === keyPath) {
|
|
65
|
+
return cachedSecretStoreKey;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const existing = (await fs.readFile(keyPath, 'utf-8')).trim();
|
|
69
|
+
const decoded = Buffer.from(existing, 'base64url');
|
|
70
|
+
if (decoded.length === PAIRING_SECRET_STORE_KEY_BYTES) {
|
|
71
|
+
cachedSecretStoreKey = decoded;
|
|
72
|
+
cachedSecretStoreKeyPath = keyPath;
|
|
73
|
+
return decoded;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// fall through to create
|
|
78
|
+
}
|
|
79
|
+
const created = crypto.randomBytes(PAIRING_SECRET_STORE_KEY_BYTES);
|
|
80
|
+
await fs.mkdir(configDir(), { recursive: true });
|
|
81
|
+
await fs.writeFile(keyPath, created.toString('base64url') + '\n', {
|
|
82
|
+
mode: 0o600,
|
|
83
|
+
});
|
|
84
|
+
cachedSecretStoreKey = created;
|
|
85
|
+
cachedSecretStoreKeyPath = keyPath;
|
|
86
|
+
return created;
|
|
87
|
+
}
|
|
25
88
|
async function readStore() {
|
|
26
89
|
try {
|
|
27
90
|
const raw = await fs.readFile(pairingStorePath(), 'utf-8');
|
|
@@ -41,7 +104,14 @@ async function readStore() {
|
|
|
41
104
|
async function writeStore(store) {
|
|
42
105
|
await fs.mkdir(configDir(), { recursive: true });
|
|
43
106
|
const compacted = compactOffers(store.offers);
|
|
44
|
-
|
|
107
|
+
const sanitized = compacted.map((offer) => {
|
|
108
|
+
const { token: _legacyToken, ...rest } = offer;
|
|
109
|
+
return rest;
|
|
110
|
+
});
|
|
111
|
+
await fs.writeFile(pairingStorePath(), JSON.stringify({ version: 1, offers: sanitized }, null, 2) + '\n', {
|
|
112
|
+
encoding: 'utf-8',
|
|
113
|
+
mode: 0o600,
|
|
114
|
+
});
|
|
45
115
|
}
|
|
46
116
|
function compactOffers(offers) {
|
|
47
117
|
const now = Date.now();
|
|
@@ -56,9 +126,26 @@ function compactOffers(offers) {
|
|
|
56
126
|
return fresh.slice(fresh.length - MAX_STORED_OFFERS);
|
|
57
127
|
}
|
|
58
128
|
async function appendAudit(event) {
|
|
59
|
-
await
|
|
60
|
-
|
|
61
|
-
|
|
129
|
+
return await withAuditMutationLock(async () => {
|
|
130
|
+
await fs.mkdir(configDir(), { recursive: true });
|
|
131
|
+
const auditPath = pairingAuditPath();
|
|
132
|
+
const maxBytes = pairingAuditMaxBytes();
|
|
133
|
+
try {
|
|
134
|
+
const stat = await fs.stat(auditPath);
|
|
135
|
+
if (stat.size >= maxBytes) {
|
|
136
|
+
const rotated = `${auditPath}.1`;
|
|
137
|
+
await fs.rm(rotated, { force: true });
|
|
138
|
+
await fs.rename(auditPath, rotated);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (error.code !== 'ENOENT') {
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const line = JSON.stringify({ timestamp: Date.now(), ...event });
|
|
147
|
+
await fs.appendFile(auditPath, `${line}\n`, { encoding: 'utf-8', mode: 0o600 });
|
|
148
|
+
});
|
|
62
149
|
}
|
|
63
150
|
function trustAnchorFingerprint(secret) {
|
|
64
151
|
const digest = crypto.createHash('sha256').update(secret).digest('hex');
|
|
@@ -195,13 +282,7 @@ async function readPeerBindings() {
|
|
|
195
282
|
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.peers)) {
|
|
196
283
|
return { version: 1, peers: [] };
|
|
197
284
|
}
|
|
198
|
-
return {
|
|
199
|
-
version: 1,
|
|
200
|
-
peers: parsed.peers.filter((item) => item &&
|
|
201
|
-
typeof item.peerId === 'string' &&
|
|
202
|
-
typeof item.publicKey === 'string' &&
|
|
203
|
-
typeof item.firstPairedAt === 'number'),
|
|
204
|
-
};
|
|
285
|
+
return { version: 1, peers: compactPeerBindings(parsed.peers) };
|
|
205
286
|
}
|
|
206
287
|
catch {
|
|
207
288
|
return { version: 1, peers: [] };
|
|
@@ -209,16 +290,25 @@ async function readPeerBindings() {
|
|
|
209
290
|
}
|
|
210
291
|
async function writePeerBindings(store) {
|
|
211
292
|
await fs.mkdir(configDir(), { recursive: true });
|
|
212
|
-
|
|
293
|
+
const compacted = {
|
|
294
|
+
version: 1,
|
|
295
|
+
peers: compactPeerBindings(store.peers),
|
|
296
|
+
};
|
|
297
|
+
await fs.writeFile(peerBindingPath(), JSON.stringify(compacted, null, 2) + '\n', {
|
|
213
298
|
mode: 0o600,
|
|
214
299
|
});
|
|
215
300
|
}
|
|
216
301
|
async function upsertPeerBinding(input) {
|
|
217
302
|
const store = await readPeerBindings();
|
|
218
303
|
const now = Date.now();
|
|
304
|
+
const encryptedSecret = await encryptRelayPairingSecret(input.relayPairingSecret);
|
|
219
305
|
const existing = store.peers.find((peer) => peer.peerId === input.peerId);
|
|
220
306
|
if (existing) {
|
|
221
307
|
existing.publicKey = input.publicKey;
|
|
308
|
+
existing.relayPairingSecretCiphertext = encryptedSecret.ciphertext;
|
|
309
|
+
existing.relayPairingSecretIv = encryptedSecret.iv;
|
|
310
|
+
existing.relayPairingSecretTag = encryptedSecret.tag;
|
|
311
|
+
delete existing.relayPairingSecret;
|
|
222
312
|
existing.lastPairedAt = now;
|
|
223
313
|
existing.lastOfferId = input.offerId;
|
|
224
314
|
existing.trustAnchor = input.trustAnchor;
|
|
@@ -227,6 +317,9 @@ async function upsertPeerBinding(input) {
|
|
|
227
317
|
store.peers.push({
|
|
228
318
|
peerId: input.peerId,
|
|
229
319
|
publicKey: input.publicKey,
|
|
320
|
+
relayPairingSecretCiphertext: encryptedSecret.ciphertext,
|
|
321
|
+
relayPairingSecretIv: encryptedSecret.iv,
|
|
322
|
+
relayPairingSecretTag: encryptedSecret.tag,
|
|
230
323
|
firstPairedAt: now,
|
|
231
324
|
lastPairedAt: now,
|
|
232
325
|
lastOfferId: input.offerId,
|
|
@@ -235,6 +328,129 @@ async function upsertPeerBinding(input) {
|
|
|
235
328
|
}
|
|
236
329
|
await writePeerBindings(store);
|
|
237
330
|
}
|
|
331
|
+
function compactPeerBindings(peers) {
|
|
332
|
+
const deduped = new Map();
|
|
333
|
+
for (const item of peers) {
|
|
334
|
+
if (!item ||
|
|
335
|
+
typeof item.peerId !== 'string' ||
|
|
336
|
+
item.peerId.trim().length === 0 ||
|
|
337
|
+
typeof item.publicKey !== 'string' ||
|
|
338
|
+
item.publicKey.trim().length === 0 ||
|
|
339
|
+
typeof item.firstPairedAt !== 'number') {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const existing = deduped.get(item.peerId);
|
|
343
|
+
if (!existing || (item.lastPairedAt ?? 0) >= (existing.lastPairedAt ?? 0)) {
|
|
344
|
+
deduped.set(item.peerId, {
|
|
345
|
+
...item,
|
|
346
|
+
peerId: item.peerId.trim(),
|
|
347
|
+
publicKey: item.publicKey.trim(),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const sorted = Array.from(deduped.values()).sort((a, b) => (a.lastPairedAt ?? a.firstPairedAt) - (b.lastPairedAt ?? b.firstPairedAt));
|
|
352
|
+
const maxEntries = pairingPeerBindingsMax();
|
|
353
|
+
if (sorted.length <= maxEntries)
|
|
354
|
+
return sorted;
|
|
355
|
+
return sorted.slice(sorted.length - maxEntries);
|
|
356
|
+
}
|
|
357
|
+
function deriveRelayPairingSecret(input) {
|
|
358
|
+
const salt = crypto
|
|
359
|
+
.createHash('sha256')
|
|
360
|
+
.update([
|
|
361
|
+
RELAY_PAIRING_INFO_PREFIX,
|
|
362
|
+
input.offerId,
|
|
363
|
+
input.trustAnchor,
|
|
364
|
+
input.clientPublicKey.trim(),
|
|
365
|
+
input.daemonPublicKey.trim(),
|
|
366
|
+
].join('\n'), 'utf8')
|
|
367
|
+
.digest();
|
|
368
|
+
const ikm = Buffer.from(input.redeemSecret, 'utf8');
|
|
369
|
+
const derived = crypto.hkdfSync('sha256', ikm, salt, Buffer.from(RELAY_PAIRING_INFO_PREFIX, 'utf8'), 32);
|
|
370
|
+
const bytes = Buffer.isBuffer(derived) ? derived : Buffer.from(derived);
|
|
371
|
+
return bytes.toString('base64url');
|
|
372
|
+
}
|
|
373
|
+
async function encryptRelayPairingSecret(secret) {
|
|
374
|
+
const key = await getOrCreateSecretStoreKey();
|
|
375
|
+
const iv = crypto.randomBytes(12);
|
|
376
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
377
|
+
const ciphertext = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
|
|
378
|
+
const tag = cipher.getAuthTag();
|
|
379
|
+
return {
|
|
380
|
+
ciphertext: ciphertext.toString('base64url'),
|
|
381
|
+
iv: iv.toString('base64url'),
|
|
382
|
+
tag: tag.toString('base64url'),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
async function decryptRelayPairingSecret(encrypted) {
|
|
386
|
+
if (typeof encrypted.relayPairingSecretCiphertext !== 'string' ||
|
|
387
|
+
typeof encrypted.relayPairingSecretIv !== 'string' ||
|
|
388
|
+
typeof encrypted.relayPairingSecretTag !== 'string') {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
const key = await getOrCreateSecretStoreKey();
|
|
393
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(encrypted.relayPairingSecretIv, 'base64url'));
|
|
394
|
+
decipher.setAuthTag(Buffer.from(encrypted.relayPairingSecretTag, 'base64url'));
|
|
395
|
+
const decrypted = Buffer.concat([
|
|
396
|
+
decipher.update(Buffer.from(encrypted.relayPairingSecretCiphertext, 'base64url')),
|
|
397
|
+
decipher.final(),
|
|
398
|
+
]);
|
|
399
|
+
if (decrypted.length !== 32)
|
|
400
|
+
return null;
|
|
401
|
+
return decrypted;
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
async function migrateLegacyPeerBindingSecret(peerId, legacySecret) {
|
|
408
|
+
await withStoreMutationLock(async () => {
|
|
409
|
+
const store = await readPeerBindings();
|
|
410
|
+
const binding = store.peers.find((peer) => peer.peerId === peerId);
|
|
411
|
+
if (!binding)
|
|
412
|
+
return;
|
|
413
|
+
if (typeof binding.relayPairingSecretCiphertext === 'string' &&
|
|
414
|
+
typeof binding.relayPairingSecretIv === 'string' &&
|
|
415
|
+
typeof binding.relayPairingSecretTag === 'string') {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (typeof binding.relayPairingSecret !== 'string' ||
|
|
419
|
+
binding.relayPairingSecret !== legacySecret) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const encryptedSecret = await encryptRelayPairingSecret(legacySecret);
|
|
423
|
+
binding.relayPairingSecretCiphertext = encryptedSecret.ciphertext;
|
|
424
|
+
binding.relayPairingSecretIv = encryptedSecret.iv;
|
|
425
|
+
binding.relayPairingSecretTag = encryptedSecret.tag;
|
|
426
|
+
delete binding.relayPairingSecret;
|
|
427
|
+
await writePeerBindings(store);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
export async function resolveRelayPairingSecret(peerId) {
|
|
431
|
+
if (!peerId || peerId.trim().length === 0)
|
|
432
|
+
return null;
|
|
433
|
+
const store = await readPeerBindings();
|
|
434
|
+
const binding = store.peers.find((peer) => peer.peerId === peerId);
|
|
435
|
+
if (!binding)
|
|
436
|
+
return null;
|
|
437
|
+
const decrypted = await decryptRelayPairingSecret(binding);
|
|
438
|
+
if (decrypted)
|
|
439
|
+
return decrypted;
|
|
440
|
+
if (typeof binding.relayPairingSecret === 'string') {
|
|
441
|
+
try {
|
|
442
|
+
const decoded = Buffer.from(binding.relayPairingSecret, 'base64url');
|
|
443
|
+
if (decoded.length !== 32)
|
|
444
|
+
return null;
|
|
445
|
+
await migrateLegacyPeerBindingSecret(binding.peerId, binding.relayPairingSecret);
|
|
446
|
+
return decoded;
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
238
454
|
export function createPairingClientIdentity() {
|
|
239
455
|
const keypair = crypto.generateKeyPairSync('ed25519');
|
|
240
456
|
const publicKey = keypair.publicKey.export({ type: 'spki', format: 'pem' }).toString().trim();
|
|
@@ -279,129 +495,144 @@ export async function rotateAuthToken() {
|
|
|
279
495
|
return { token, previousTokenExisted: previous !== null };
|
|
280
496
|
}
|
|
281
497
|
export async function issuePairingOffer(input) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
498
|
+
return await withStoreMutationLock(async () => {
|
|
499
|
+
const ttlSeconds = Math.min(3600, Math.max(30, Math.floor(input.ttlSeconds)));
|
|
500
|
+
const createdAt = Date.now();
|
|
501
|
+
const expiresAt = createdAt + ttlSeconds * 1000;
|
|
502
|
+
const offerId = crypto.randomUUID();
|
|
503
|
+
const redeemSecret = crypto.randomBytes(16).toString('hex');
|
|
504
|
+
const existingToken = await readAuthToken();
|
|
505
|
+
if (!existingToken) {
|
|
506
|
+
await rotateAuthToken();
|
|
507
|
+
}
|
|
508
|
+
const trustAnchor = await getOrCreateTrustAnchor();
|
|
509
|
+
const daemonIdentity = await getOrCreateDaemonIdentity();
|
|
510
|
+
const store = await readStore();
|
|
511
|
+
store.offers.push({
|
|
512
|
+
offerId,
|
|
513
|
+
createdAt,
|
|
514
|
+
expiresAt,
|
|
515
|
+
redeemSecretHash: await hashSecret(redeemSecret),
|
|
516
|
+
trustAnchor: trustAnchor.fingerprint,
|
|
517
|
+
daemonDeviceId: daemonIdentity.deviceId,
|
|
518
|
+
daemonPublicKey: daemonIdentity.publicKey,
|
|
519
|
+
connection: input.connection,
|
|
520
|
+
});
|
|
521
|
+
await writeStore(store);
|
|
522
|
+
await appendAudit({
|
|
523
|
+
event: 'pair_offer_issued',
|
|
524
|
+
offerId,
|
|
525
|
+
createdAt,
|
|
526
|
+
expiresAt,
|
|
527
|
+
profile: input.connection.profile,
|
|
528
|
+
listen: input.connection.listen,
|
|
529
|
+
trustAnchor: trustAnchor.fingerprint,
|
|
530
|
+
daemonDeviceId: daemonIdentity.deviceId,
|
|
531
|
+
});
|
|
532
|
+
return {
|
|
533
|
+
offerId,
|
|
534
|
+
createdAt,
|
|
535
|
+
expiresAt,
|
|
536
|
+
redeemSecret,
|
|
537
|
+
trustAnchor: trustAnchor.fingerprint,
|
|
538
|
+
daemonDeviceId: daemonIdentity.deviceId,
|
|
539
|
+
daemonPublicKey: daemonIdentity.publicKey,
|
|
540
|
+
...input.connection,
|
|
541
|
+
};
|
|
315
542
|
});
|
|
316
|
-
return {
|
|
317
|
-
offerId,
|
|
318
|
-
createdAt,
|
|
319
|
-
expiresAt,
|
|
320
|
-
redeemSecret,
|
|
321
|
-
trustAnchor: trustAnchor.fingerprint,
|
|
322
|
-
daemonDeviceId: daemonIdentity.deviceId,
|
|
323
|
-
daemonPublicKey: daemonIdentity.publicKey,
|
|
324
|
-
...input.connection,
|
|
325
|
-
};
|
|
326
543
|
}
|
|
327
544
|
export async function listPairingOffers() {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
545
|
+
return await withStoreMutationLock(async () => {
|
|
546
|
+
const store = await readStore();
|
|
547
|
+
const now = Date.now();
|
|
548
|
+
return store.offers
|
|
549
|
+
.map((offer) => {
|
|
550
|
+
const expired = offer.expiresAt <= now;
|
|
551
|
+
const active = !expired && !offer.revokedAt && !offer.redeemedAt;
|
|
552
|
+
return {
|
|
553
|
+
offerId: offer.offerId,
|
|
554
|
+
createdAt: offer.createdAt,
|
|
555
|
+
expiresAt: offer.expiresAt,
|
|
556
|
+
trustAnchor: offer.trustAnchor,
|
|
557
|
+
daemonDeviceId: offer.daemonDeviceId,
|
|
558
|
+
host: offer.connection.host,
|
|
559
|
+
port: offer.connection.port,
|
|
560
|
+
listen: offer.connection.listen,
|
|
561
|
+
socketPath: offer.connection.socketPath,
|
|
562
|
+
profile: offer.connection.profile,
|
|
563
|
+
revokedAt: offer.revokedAt,
|
|
564
|
+
redeemedAt: offer.redeemedAt,
|
|
565
|
+
active,
|
|
566
|
+
expired,
|
|
567
|
+
};
|
|
568
|
+
})
|
|
569
|
+
.sort((a, b) => b.createdAt - a.createdAt);
|
|
570
|
+
});
|
|
352
571
|
}
|
|
353
572
|
export async function revokePairingOffer(offerId) {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
offer.revokedAt
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
573
|
+
return await withStoreMutationLock(async () => {
|
|
574
|
+
const store = await readStore();
|
|
575
|
+
const offer = store.offers.find((item) => item.offerId === offerId);
|
|
576
|
+
if (!offer)
|
|
577
|
+
return false;
|
|
578
|
+
if (!offer.revokedAt) {
|
|
579
|
+
offer.revokedAt = Date.now();
|
|
580
|
+
await writeStore(store);
|
|
581
|
+
await appendAudit({ event: 'pair_offer_revoked', offerId: offer.offerId });
|
|
582
|
+
}
|
|
583
|
+
return true;
|
|
584
|
+
});
|
|
364
585
|
}
|
|
365
586
|
export async function redeemPairingOffer(offerId, redeemSecret, expectedTrustAnchor, clientPublicKey, clientProof) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
587
|
+
return await withStoreMutationLock(async () => {
|
|
588
|
+
if (!redeemSecret || redeemSecret.trim().length === 0) {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
const store = await readStore();
|
|
592
|
+
const offer = store.offers.find((item) => item.offerId === offerId);
|
|
593
|
+
if (!offer)
|
|
594
|
+
return null;
|
|
595
|
+
const now = Date.now();
|
|
596
|
+
const expired = offer.expiresAt <= now;
|
|
597
|
+
if (expired || offer.revokedAt || offer.redeemedAt || offer.lockedAt) {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
if (!clientPublicKey || !clientProof) {
|
|
601
|
+
await appendAudit({
|
|
602
|
+
event: 'pair_offer_redeem_failed',
|
|
603
|
+
offerId: offer.offerId,
|
|
604
|
+
reason: 'missing_client_identity_proof',
|
|
605
|
+
});
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
if (expectedTrustAnchor && offer.trustAnchor !== expectedTrustAnchor) {
|
|
609
|
+
await appendAudit({
|
|
610
|
+
event: 'pair_offer_redeem_failed',
|
|
611
|
+
offerId: offer.offerId,
|
|
612
|
+
reason: 'trust_anchor_mismatch',
|
|
613
|
+
expectedTrustAnchor,
|
|
614
|
+
offeredTrustAnchor: offer.trustAnchor,
|
|
615
|
+
});
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
const payload = canonicalRedeemPayload({
|
|
620
|
+
offerId: offer.offerId,
|
|
621
|
+
redeemSecret,
|
|
622
|
+
trustAnchor: offer.trustAnchor,
|
|
623
|
+
clientPublicKey,
|
|
624
|
+
});
|
|
625
|
+
const verified = crypto.verify(null, Buffer.from(payload, 'utf-8'), crypto.createPublicKey(clientPublicKey), Buffer.from(clientProof, 'base64url'));
|
|
626
|
+
if (!verified) {
|
|
627
|
+
await appendAudit({
|
|
628
|
+
event: 'pair_offer_redeem_failed',
|
|
629
|
+
offerId: offer.offerId,
|
|
630
|
+
reason: 'client_proof_invalid',
|
|
631
|
+
});
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
405
636
|
await appendAudit({
|
|
406
637
|
event: 'pair_offer_redeem_failed',
|
|
407
638
|
offerId: offer.offerId,
|
|
@@ -409,94 +640,97 @@ export async function redeemPairingOffer(offerId, redeemSecret, expectedTrustAnc
|
|
|
409
640
|
});
|
|
410
641
|
return null;
|
|
411
642
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
643
|
+
if (typeof offer.redeemSecretHash !== 'string' || offer.redeemSecretHash.length === 0) {
|
|
644
|
+
offer.lockedAt = now;
|
|
645
|
+
await writeStore(store);
|
|
646
|
+
await appendAudit({
|
|
647
|
+
event: 'pair_offer_redeem_failed',
|
|
648
|
+
offerId: offer.offerId,
|
|
649
|
+
reason: 'missing_redeem_secret_hash',
|
|
650
|
+
});
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
const proofValid = secureSecretCompare(offer.redeemSecretHash, await hashSecret(redeemSecret));
|
|
654
|
+
if (!proofValid) {
|
|
655
|
+
offer.failedRedeemAttempts = (offer.failedRedeemAttempts ?? 0) + 1;
|
|
656
|
+
if (offer.failedRedeemAttempts >= MAX_FAILED_REDEEM_ATTEMPTS) {
|
|
657
|
+
offer.lockedAt = now;
|
|
658
|
+
}
|
|
659
|
+
await writeStore(store);
|
|
660
|
+
await appendAudit({
|
|
661
|
+
event: 'pair_offer_redeem_failed',
|
|
662
|
+
offerId: offer.offerId,
|
|
663
|
+
attempts: offer.failedRedeemAttempts,
|
|
664
|
+
locked: !!offer.lockedAt,
|
|
665
|
+
});
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
offer.redeemedAt = now;
|
|
669
|
+
await writeStore(store);
|
|
670
|
+
const peerId = peerIdFromPublicKey(clientPublicKey);
|
|
671
|
+
const relayPairingSecret = deriveRelayPairingSecret({
|
|
416
672
|
offerId: offer.offerId,
|
|
417
|
-
|
|
673
|
+
redeemSecret,
|
|
674
|
+
trustAnchor: offer.trustAnchor,
|
|
675
|
+
clientPublicKey,
|
|
676
|
+
daemonPublicKey: offer.daemonPublicKey,
|
|
418
677
|
});
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
await writeStore(store);
|
|
424
|
-
await appendAudit({
|
|
425
|
-
event: 'pair_offer_redeem_failed',
|
|
678
|
+
await upsertPeerBinding({
|
|
679
|
+
peerId,
|
|
680
|
+
publicKey: clientPublicKey,
|
|
681
|
+
relayPairingSecret,
|
|
426
682
|
offerId: offer.offerId,
|
|
427
|
-
|
|
683
|
+
trustAnchor: offer.trustAnchor,
|
|
428
684
|
});
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
offer.
|
|
436
|
-
|
|
437
|
-
|
|
685
|
+
const daemonIdentity = await readDaemonIdentity();
|
|
686
|
+
const daemonPrivateKey = daemonIdentity
|
|
687
|
+
? crypto.createPrivateKey(daemonIdentity.privateKey)
|
|
688
|
+
: undefined;
|
|
689
|
+
const redeemEnvelope = [
|
|
690
|
+
'viewport-pair-redeem-response-v1',
|
|
691
|
+
offer.offerId,
|
|
692
|
+
peerId,
|
|
693
|
+
offer.trustAnchor,
|
|
694
|
+
String(offer.expiresAt),
|
|
695
|
+
].join('\n');
|
|
696
|
+
const serverSignature = daemonPrivateKey
|
|
697
|
+
? crypto
|
|
698
|
+
.sign(null, Buffer.from(redeemEnvelope, 'utf-8'), daemonPrivateKey)
|
|
699
|
+
.toString('base64url')
|
|
700
|
+
: '';
|
|
438
701
|
await appendAudit({
|
|
439
|
-
event: '
|
|
702
|
+
event: 'pair_offer_redeemed',
|
|
440
703
|
offerId: offer.offerId,
|
|
441
|
-
|
|
442
|
-
|
|
704
|
+
peerId,
|
|
705
|
+
daemonDeviceId: offer.daemonDeviceId,
|
|
443
706
|
});
|
|
444
|
-
return
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const daemonPrivateKey = daemonIdentity
|
|
457
|
-
? crypto.createPrivateKey(daemonIdentity.privateKey)
|
|
458
|
-
: undefined;
|
|
459
|
-
const redeemEnvelope = [
|
|
460
|
-
'viewport-pair-redeem-response-v1',
|
|
461
|
-
offer.offerId,
|
|
462
|
-
peerId,
|
|
463
|
-
offer.trustAnchor,
|
|
464
|
-
String(offer.expiresAt),
|
|
465
|
-
].join('\n');
|
|
466
|
-
const serverSignature = daemonPrivateKey
|
|
467
|
-
? crypto
|
|
468
|
-
.sign(null, Buffer.from(redeemEnvelope, 'utf-8'), daemonPrivateKey)
|
|
469
|
-
.toString('base64url')
|
|
470
|
-
: '';
|
|
471
|
-
await appendAudit({
|
|
472
|
-
event: 'pair_offer_redeemed',
|
|
473
|
-
offerId: offer.offerId,
|
|
474
|
-
peerId,
|
|
475
|
-
daemonDeviceId: offer.daemonDeviceId,
|
|
707
|
+
return {
|
|
708
|
+
offerId: offer.offerId,
|
|
709
|
+
trustAnchor: offer.trustAnchor,
|
|
710
|
+
daemonDeviceId: offer.daemonDeviceId,
|
|
711
|
+
daemonPublicKey: offer.daemonPublicKey,
|
|
712
|
+
peerId,
|
|
713
|
+
relayPairingPeerId: peerId,
|
|
714
|
+
serverSignature,
|
|
715
|
+
connection: offer.connection,
|
|
716
|
+
expiresAt: offer.expiresAt,
|
|
717
|
+
createdAt: offer.createdAt,
|
|
718
|
+
};
|
|
476
719
|
});
|
|
477
|
-
return {
|
|
478
|
-
offerId: offer.offerId,
|
|
479
|
-
token: offer.token,
|
|
480
|
-
trustAnchor: offer.trustAnchor,
|
|
481
|
-
daemonDeviceId: offer.daemonDeviceId,
|
|
482
|
-
daemonPublicKey: offer.daemonPublicKey,
|
|
483
|
-
peerId,
|
|
484
|
-
serverSignature,
|
|
485
|
-
connection: offer.connection,
|
|
486
|
-
expiresAt: offer.expiresAt,
|
|
487
|
-
createdAt: offer.createdAt,
|
|
488
|
-
};
|
|
489
720
|
}
|
|
490
|
-
function hashSecret(secret) {
|
|
491
|
-
|
|
721
|
+
async function hashSecret(secret) {
|
|
722
|
+
const key = await getOrCreateSecretStoreKey();
|
|
723
|
+
return crypto.createHmac('sha256', key).update(secret, 'utf8').digest('hex');
|
|
492
724
|
}
|
|
493
725
|
function secureSecretCompare(a, b) {
|
|
494
726
|
const left = Buffer.from(a, 'utf-8');
|
|
495
727
|
const right = Buffer.from(b, 'utf-8');
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
728
|
+
const compareLength = Math.max(left.length, right.length, 1);
|
|
729
|
+
const paddedLeft = Buffer.alloc(compareLength);
|
|
730
|
+
const paddedRight = Buffer.alloc(compareLength);
|
|
731
|
+
left.copy(paddedLeft);
|
|
732
|
+
right.copy(paddedRight);
|
|
733
|
+
const equal = crypto.timingSafeEqual(paddedLeft, paddedRight);
|
|
734
|
+
return equal && left.length === right.length;
|
|
501
735
|
}
|
|
502
736
|
//# sourceMappingURL=pairing-offers.js.map
|