@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.
Files changed (136) hide show
  1. package/dist/cli/commands.d.ts +1 -0
  2. package/dist/cli/commands.d.ts.map +1 -1
  3. package/dist/cli/commands.js +1 -0
  4. package/dist/cli/commands.js.map +1 -1
  5. package/dist/cli/daemon-lifecycle.d.ts +3 -0
  6. package/dist/cli/daemon-lifecycle.d.ts.map +1 -1
  7. package/dist/cli/daemon-lifecycle.js +11 -1
  8. package/dist/cli/daemon-lifecycle.js.map +1 -1
  9. package/dist/cli/daemon-settings.d.ts.map +1 -1
  10. package/dist/cli/daemon-settings.js +115 -3
  11. package/dist/cli/daemon-settings.js.map +1 -1
  12. package/dist/cli/lifecycle-commands.d.ts.map +1 -1
  13. package/dist/cli/lifecycle-commands.js +2 -0
  14. package/dist/cli/lifecycle-commands.js.map +1 -1
  15. package/dist/cli/remote-commands.d.ts +3 -0
  16. package/dist/cli/remote-commands.d.ts.map +1 -0
  17. package/dist/cli/remote-commands.js +236 -0
  18. package/dist/cli/remote-commands.js.map +1 -0
  19. package/dist/cli/setup-command.d.ts.map +1 -1
  20. package/dist/cli/setup-command.js +4 -1
  21. package/dist/cli/setup-command.js.map +1 -1
  22. package/dist/cli/supervisor-protocol.d.ts +12 -0
  23. package/dist/cli/supervisor-protocol.d.ts.map +1 -1
  24. package/dist/cli/supervisor.d.ts.map +1 -1
  25. package/dist/cli/supervisor.js +30 -0
  26. package/dist/cli/supervisor.js.map +1 -1
  27. package/dist/core/config-schema.d.ts +16 -0
  28. package/dist/core/config-schema.d.ts.map +1 -1
  29. package/dist/core/config-schema.js +12 -0
  30. package/dist/core/config-schema.js.map +1 -1
  31. package/dist/core/config.d.ts +23 -0
  32. package/dist/core/config.d.ts.map +1 -1
  33. package/dist/core/config.js +46 -3
  34. package/dist/core/config.js.map +1 -1
  35. package/dist/core/session-state-file.d.ts.map +1 -1
  36. package/dist/core/session-state-file.js +3 -1
  37. package/dist/core/session-state-file.js.map +1 -1
  38. package/dist/core/types.d.ts +7 -0
  39. package/dist/core/types.d.ts.map +1 -1
  40. package/dist/hooks/installers/claude.js +4 -1
  41. package/dist/hooks/installers/claude.js.map +1 -1
  42. package/dist/hooks/router.d.ts.map +1 -1
  43. package/dist/hooks/router.js +11 -0
  44. package/dist/hooks/router.js.map +1 -1
  45. package/dist/hooks/supervision.d.ts +2 -0
  46. package/dist/hooks/supervision.d.ts.map +1 -1
  47. package/dist/hooks/supervision.js +12 -0
  48. package/dist/hooks/supervision.js.map +1 -1
  49. package/dist/index.js +5 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/plugins/loader.d.ts.map +1 -1
  52. package/dist/plugins/loader.js +14 -0
  53. package/dist/plugins/loader.js.map +1 -1
  54. package/dist/relay/bridge-backoff.d.ts +3 -0
  55. package/dist/relay/bridge-backoff.d.ts.map +1 -0
  56. package/dist/relay/bridge-backoff.js +10 -0
  57. package/dist/relay/bridge-backoff.js.map +1 -0
  58. package/dist/relay/bridge-constants.d.ts +12 -0
  59. package/dist/relay/bridge-constants.d.ts.map +1 -0
  60. package/dist/relay/bridge-constants.js +12 -0
  61. package/dist/relay/bridge-constants.js.map +1 -0
  62. package/dist/relay/bridge-crypto.d.ts +18 -0
  63. package/dist/relay/bridge-crypto.d.ts.map +1 -0
  64. package/dist/relay/bridge-crypto.js +63 -0
  65. package/dist/relay/bridge-crypto.js.map +1 -0
  66. package/dist/relay/bridge-errors.d.ts +6 -0
  67. package/dist/relay/bridge-errors.d.ts.map +1 -0
  68. package/dist/relay/bridge-errors.js +9 -0
  69. package/dist/relay/bridge-errors.js.map +1 -0
  70. package/dist/relay/bridge-jwt.d.ts +18 -0
  71. package/dist/relay/bridge-jwt.d.ts.map +1 -0
  72. package/dist/relay/bridge-jwt.js +130 -0
  73. package/dist/relay/bridge-jwt.js.map +1 -0
  74. package/dist/relay/bridge-key-exchange.d.ts +49 -0
  75. package/dist/relay/bridge-key-exchange.d.ts.map +1 -0
  76. package/dist/relay/bridge-key-exchange.js +234 -0
  77. package/dist/relay/bridge-key-exchange.js.map +1 -0
  78. package/dist/relay/bridge-network.d.ts +12 -0
  79. package/dist/relay/bridge-network.d.ts.map +1 -0
  80. package/dist/relay/bridge-network.js +90 -0
  81. package/dist/relay/bridge-network.js.map +1 -0
  82. package/dist/relay/bridge-noise-v3.d.ts +74 -0
  83. package/dist/relay/bridge-noise-v3.d.ts.map +1 -0
  84. package/dist/relay/bridge-noise-v3.js +403 -0
  85. package/dist/relay/bridge-noise-v3.js.map +1 -0
  86. package/dist/relay/daemon-relay-bridge.d.ts +93 -0
  87. package/dist/relay/daemon-relay-bridge.d.ts.map +1 -0
  88. package/dist/relay/daemon-relay-bridge.js +1005 -0
  89. package/dist/relay/daemon-relay-bridge.js.map +1 -0
  90. package/dist/server/auth.d.ts.map +1 -1
  91. package/dist/server/auth.js +9 -7
  92. package/dist/server/auth.js.map +1 -1
  93. package/dist/server/http-server.d.ts +6 -0
  94. package/dist/server/http-server.d.ts.map +1 -1
  95. package/dist/server/http-server.js +102 -15
  96. package/dist/server/http-server.js.map +1 -1
  97. package/dist/server/pairing-offers.d.ts +2 -1
  98. package/dist/server/pairing-offers.d.ts.map +1 -1
  99. package/dist/server/pairing-offers.js +438 -204
  100. package/dist/server/pairing-offers.js.map +1 -1
  101. package/dist/server/ring-buffer.d.ts +48 -7
  102. package/dist/server/ring-buffer.d.ts.map +1 -1
  103. package/dist/server/ring-buffer.js +387 -14
  104. package/dist/server/ring-buffer.js.map +1 -1
  105. package/dist/server/security.d.ts.map +1 -1
  106. package/dist/server/security.js +5 -1
  107. package/dist/server/security.js.map +1 -1
  108. package/dist/server/ws-command-handlers.d.ts.map +1 -1
  109. package/dist/server/ws-command-handlers.js +18 -6
  110. package/dist/server/ws-command-handlers.js.map +1 -1
  111. package/dist/server/ws-daemon-event-bridge.d.ts.map +1 -1
  112. package/dist/server/ws-daemon-event-bridge.js +14 -2
  113. package/dist/server/ws-daemon-event-bridge.js.map +1 -1
  114. package/dist/server/ws-server.d.ts.map +1 -1
  115. package/dist/server/ws-server.js +26 -3
  116. package/dist/server/ws-server.js.map +1 -1
  117. package/dist/startup-relay-security.d.ts +3 -0
  118. package/dist/startup-relay-security.d.ts.map +1 -0
  119. package/dist/startup-relay-security.js +61 -0
  120. package/dist/startup-relay-security.js.map +1 -0
  121. package/dist/startup-session-persistence.d.ts +7 -0
  122. package/dist/startup-session-persistence.d.ts.map +1 -0
  123. package/dist/startup-session-persistence.js +72 -0
  124. package/dist/startup-session-persistence.js.map +1 -0
  125. package/dist/startup.d.ts.map +1 -1
  126. package/dist/startup.js +115 -65
  127. package/dist/startup.js.map +1 -1
  128. package/dist/tracking/git-tracker.d.ts +4 -0
  129. package/dist/tracking/git-tracker.d.ts.map +1 -1
  130. package/dist/tracking/git-tracker.js +80 -15
  131. package/dist/tracking/git-tracker.js.map +1 -1
  132. package/docs/configuration.md +63 -5
  133. package/docs/relay-noise-conformance-vectors.json +41 -0
  134. package/docs/relay-noise-v3-conformance-vectors.json +50 -0
  135. package/docs/security.md +3 -2
  136. 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
- await fs.writeFile(pairingStorePath(), JSON.stringify({ version: 1, offers: compacted }, null, 2) + '\n', 'utf-8');
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 fs.mkdir(configDir(), { recursive: true });
60
- const line = JSON.stringify({ timestamp: Date.now(), ...event });
61
- await fs.appendFile(pairingAuditPath(), `${line}\n`, 'utf-8');
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
- await fs.writeFile(peerBindingPath(), JSON.stringify(store, null, 2) + '\n', {
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
- const ttlSeconds = Math.min(3600, Math.max(30, Math.floor(input.ttlSeconds)));
283
- const createdAt = Date.now();
284
- const expiresAt = createdAt + ttlSeconds * 1000;
285
- const offerId = crypto.randomUUID();
286
- const redeemSecret = crypto.randomBytes(16).toString('hex');
287
- const token = await readAuthToken();
288
- if (!token) {
289
- throw new Error('No auth token available for pairing offer');
290
- }
291
- const trustAnchor = await getOrCreateTrustAnchor();
292
- const daemonIdentity = await getOrCreateDaemonIdentity();
293
- const store = await readStore();
294
- store.offers.push({
295
- offerId,
296
- createdAt,
297
- expiresAt,
298
- redeemSecretHash: hashSecret(redeemSecret),
299
- token,
300
- trustAnchor: trustAnchor.fingerprint,
301
- daemonDeviceId: daemonIdentity.deviceId,
302
- daemonPublicKey: daemonIdentity.publicKey,
303
- connection: input.connection,
304
- });
305
- await writeStore(store);
306
- await appendAudit({
307
- event: 'pair_offer_issued',
308
- offerId,
309
- createdAt,
310
- expiresAt,
311
- profile: input.connection.profile,
312
- listen: input.connection.listen,
313
- trustAnchor: trustAnchor.fingerprint,
314
- daemonDeviceId: daemonIdentity.deviceId,
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
- const store = await readStore();
329
- const now = Date.now();
330
- return store.offers
331
- .map((offer) => {
332
- const expired = offer.expiresAt <= now;
333
- const active = !expired && !offer.revokedAt && !offer.redeemedAt;
334
- return {
335
- offerId: offer.offerId,
336
- createdAt: offer.createdAt,
337
- expiresAt: offer.expiresAt,
338
- trustAnchor: offer.trustAnchor,
339
- daemonDeviceId: offer.daemonDeviceId,
340
- host: offer.connection.host,
341
- port: offer.connection.port,
342
- listen: offer.connection.listen,
343
- socketPath: offer.connection.socketPath,
344
- profile: offer.connection.profile,
345
- revokedAt: offer.revokedAt,
346
- redeemedAt: offer.redeemedAt,
347
- active,
348
- expired,
349
- };
350
- })
351
- .sort((a, b) => b.createdAt - a.createdAt);
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
- const store = await readStore();
355
- const offer = store.offers.find((item) => item.offerId === offerId);
356
- if (!offer)
357
- return false;
358
- if (!offer.revokedAt) {
359
- offer.revokedAt = Date.now();
360
- await writeStore(store);
361
- await appendAudit({ event: 'pair_offer_revoked', offerId: offer.offerId });
362
- }
363
- return true;
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
- if (!redeemSecret || redeemSecret.trim().length === 0) {
367
- return null;
368
- }
369
- const store = await readStore();
370
- const offer = store.offers.find((item) => item.offerId === offerId);
371
- if (!offer)
372
- return null;
373
- const now = Date.now();
374
- const expired = offer.expiresAt <= now;
375
- if (expired || offer.revokedAt || offer.redeemedAt || offer.lockedAt) {
376
- return null;
377
- }
378
- if (!clientPublicKey || !clientProof) {
379
- await appendAudit({
380
- event: 'pair_offer_redeem_failed',
381
- offerId: offer.offerId,
382
- reason: 'missing_client_identity_proof',
383
- });
384
- return null;
385
- }
386
- if (expectedTrustAnchor && offer.trustAnchor !== expectedTrustAnchor) {
387
- await appendAudit({
388
- event: 'pair_offer_redeem_failed',
389
- offerId: offer.offerId,
390
- reason: 'trust_anchor_mismatch',
391
- expectedTrustAnchor,
392
- offeredTrustAnchor: offer.trustAnchor,
393
- });
394
- return null;
395
- }
396
- try {
397
- const payload = canonicalRedeemPayload({
398
- offerId: offer.offerId,
399
- redeemSecret,
400
- trustAnchor: offer.trustAnchor,
401
- clientPublicKey,
402
- });
403
- const verified = crypto.verify(null, Buffer.from(payload, 'utf-8'), crypto.createPublicKey(clientPublicKey), Buffer.from(clientProof, 'base64url'));
404
- if (!verified) {
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
- catch {
414
- await appendAudit({
415
- event: 'pair_offer_redeem_failed',
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
- reason: 'client_proof_invalid',
673
+ redeemSecret,
674
+ trustAnchor: offer.trustAnchor,
675
+ clientPublicKey,
676
+ daemonPublicKey: offer.daemonPublicKey,
418
677
  });
419
- return null;
420
- }
421
- if (typeof offer.redeemSecretHash !== 'string' || offer.redeemSecretHash.length === 0) {
422
- offer.lockedAt = now;
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
- reason: 'missing_redeem_secret_hash',
683
+ trustAnchor: offer.trustAnchor,
428
684
  });
429
- return null;
430
- }
431
- const proofValid = secureSecretCompare(offer.redeemSecretHash, hashSecret(redeemSecret));
432
- if (!proofValid) {
433
- offer.failedRedeemAttempts = (offer.failedRedeemAttempts ?? 0) + 1;
434
- if (offer.failedRedeemAttempts >= MAX_FAILED_REDEEM_ATTEMPTS) {
435
- offer.lockedAt = now;
436
- }
437
- await writeStore(store);
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: 'pair_offer_redeem_failed',
702
+ event: 'pair_offer_redeemed',
440
703
  offerId: offer.offerId,
441
- attempts: offer.failedRedeemAttempts,
442
- locked: !!offer.lockedAt,
704
+ peerId,
705
+ daemonDeviceId: offer.daemonDeviceId,
443
706
  });
444
- return null;
445
- }
446
- offer.redeemedAt = now;
447
- await writeStore(store);
448
- const peerId = peerIdFromPublicKey(clientPublicKey);
449
- await upsertPeerBinding({
450
- peerId,
451
- publicKey: clientPublicKey,
452
- offerId: offer.offerId,
453
- trustAnchor: offer.trustAnchor,
454
- });
455
- const daemonIdentity = await readDaemonIdentity();
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
- return crypto.createHash('sha256').update(secret).digest('hex');
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
- if (left.length !== right.length) {
497
- crypto.timingSafeEqual(left, left);
498
- return false;
499
- }
500
- return crypto.timingSafeEqual(left, right);
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