pipane 0.1.6 → 0.1.8

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 (58) hide show
  1. package/README.md +14 -3
  2. package/bin/pipane-rendezvous.js +21 -0
  3. package/bin/pipane.js +21 -1
  4. package/dist/build-info.json +5 -0
  5. package/dist/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
  6. package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
  7. package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
  8. package/dist/client/assets/index-C7_1ODks.js +2 -0
  9. package/dist/client/assets/index-DgI_gkjg.css +1 -0
  10. package/dist/client/assets/main-DEfSB8wO.js +2822 -0
  11. package/dist/client/assets/pairing-page-C0YByC2D.js +1 -0
  12. package/dist/client/assets/remote-backend-manager-DCSdS38m.js +1 -0
  13. package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
  14. package/dist/client/assets/webrtc-frame-transport-DGX5EO_A.js +1 -0
  15. package/dist/client/assets/ws-agent-adapter-DpkkZGPl.js +6 -0
  16. package/dist/client/index.html +2 -2
  17. package/dist/server/rendezvous/rendezvous-hub.js +426 -0
  18. package/dist/server/rendezvous/server.js +247 -0
  19. package/dist/server/rendezvous/trust-store.js +432 -0
  20. package/dist/server/server/auth-guard.js +61 -0
  21. package/dist/server/server/backend-connection-authorizer.js +29 -0
  22. package/dist/server/server/backend-identity.js +167 -0
  23. package/dist/server/server/backend-protocol-handler.js +132 -0
  24. package/dist/server/server/backend-trust-store.js +157 -0
  25. package/dist/server/server/backend-webrtc.js +245 -0
  26. package/dist/server/server/build-info.js +13 -0
  27. package/dist/server/server/conversation-file-access.js +97 -0
  28. package/dist/server/server/frame-connection.js +106 -0
  29. package/dist/server/server/frame-router.js +45 -0
  30. package/dist/server/server/ice-servers.js +24 -0
  31. package/dist/server/server/local-backend-api.js +389 -0
  32. package/dist/server/server/local-settings.js +17 -4
  33. package/dist/server/server/pi-rpc-protocol.js +407 -0
  34. package/dist/server/server/process-pool.js +27 -24
  35. package/dist/server/server/rendezvous-client.js +282 -0
  36. package/dist/server/server/rest-api.js +133 -176
  37. package/dist/server/server/server.js +167 -97
  38. package/dist/server/server/session-index.js +105 -28
  39. package/dist/server/server/session-jsonl.js +82 -5
  40. package/dist/server/server/session-path.js +145 -0
  41. package/dist/server/server/update-api.js +28 -0
  42. package/dist/server/server/update-check.js +33 -13
  43. package/dist/server/server/update-manager.js +233 -0
  44. package/dist/server/server/worktree-name.js +116 -31
  45. package/dist/server/server/ws-handler.js +267 -186
  46. package/dist/server/shared/backend-api.js +1 -0
  47. package/dist/server/shared/backend-protocol.js +164 -0
  48. package/dist/server/shared/data-channel-framing.js +137 -0
  49. package/dist/server/shared/node-trust-crypto.js +61 -0
  50. package/dist/server/shared/rendezvous-protocol.js +243 -0
  51. package/dist/server/shared/tool-runtime.js +1 -0
  52. package/dist/server/shared/trust-protocol.js +97 -0
  53. package/dist/server/shared/updates.js +4 -0
  54. package/dist/server/shared/ws-protocol.js +473 -1
  55. package/docs/protocol.md +129 -0
  56. package/package.json +21 -8
  57. package/dist/client/assets/index-Dl_wdLZH.css +0 -1
  58. package/dist/client/assets/index-hNqbnG06.js +0 -2482
@@ -0,0 +1,432 @@
1
+ import { createHmac, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from "node:crypto";
2
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { CONNECTION_TICKET_VERSION, connectionTicketSignaturePayload, deviceChallengePayload, TRUST_PROTOCOL_VERSION, } from "../shared/trust-protocol.js";
6
+ import { assertP256Key, deriveDeviceId, verifyConnectionTicket, verifyDeviceSignature, } from "../shared/node-trust-crypto.js";
7
+ export { deriveDeviceId, verifyConnectionTicket, verifyDeviceSignature } from "../shared/node-trust-crypto.js";
8
+ const STORE_VERSION = 1;
9
+ const SIGNING_IDENTITY_VERSION = 1;
10
+ const ACCOUNT_ID_PREFIX = "a_";
11
+ const TICKET_ID_PREFIX = "t_";
12
+ const CHALLENGE_ID_PREFIX = "ch_";
13
+ const DEFAULT_CHALLENGE_TTL_MS = 2 * 60_000;
14
+ const DEFAULT_TICKET_TTL_MS = 60_000;
15
+ export class IceServerProvider {
16
+ staticServers;
17
+ turn;
18
+ now;
19
+ constructor(staticServers = [], turn, now = Date.now) {
20
+ this.staticServers = staticServers;
21
+ this.turn = turn;
22
+ this.now = now;
23
+ }
24
+ issue(subject) {
25
+ const result = this.staticServers.map((server) => ({
26
+ ...server,
27
+ urls: Array.isArray(server.urls) ? [...server.urls] : server.urls,
28
+ }));
29
+ if (!this.turn || this.turn.urls.length === 0)
30
+ return result;
31
+ const expiresAtSeconds = Math.floor(this.now() / 1000) + (this.turn.ttlSeconds ?? 600);
32
+ const username = `${expiresAtSeconds}:${subject}`;
33
+ const credential = createHmac("sha1", this.turn.secret).update(username).digest("base64");
34
+ result.push({ urls: [...this.turn.urls], username, credential });
35
+ return result;
36
+ }
37
+ }
38
+ export class RendezvousTrustStore {
39
+ ticketPublicKey;
40
+ now;
41
+ challengeTtlMs;
42
+ ticketTtlMs;
43
+ statePath;
44
+ signing;
45
+ challenges = new Map();
46
+ state;
47
+ constructor(options = {}) {
48
+ const dataDir = options.dataDir ?? process.env.PIPANE_RENDEZVOUS_DATA_DIR ?? path.join(homedir(), ".config", "pipane-rendezvous");
49
+ mkdirSync(dataDir, { recursive: true, mode: 0o700 });
50
+ this.now = options.now ?? Date.now;
51
+ this.challengeTtlMs = options.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS;
52
+ this.ticketTtlMs = options.ticketTtlMs ?? DEFAULT_TICKET_TTL_MS;
53
+ this.statePath = path.join(dataDir, "trust-store.json");
54
+ this.signing = loadOrCreateSigningIdentity(path.join(dataDir, "ticket-identity.json"));
55
+ this.ticketPublicKey = this.signing.publicKey;
56
+ this.state = loadTrustState(this.statePath);
57
+ this.persist();
58
+ }
59
+ createChallenge(request) {
60
+ this.prune();
61
+ this.validateChallengeContext(request);
62
+ const identity = this.resolveChallengeIdentity(request);
63
+ const challenge = {
64
+ version: TRUST_PROTOCOL_VERSION,
65
+ challengeId: `${CHALLENGE_ID_PREFIX}${randomUUID()}`,
66
+ nonce: randomBytes(32).toString("base64url"),
67
+ purpose: request.purpose,
68
+ deviceId: identity.deviceId,
69
+ devicePublicKey: identity.publicKey,
70
+ backendId: request.backendId,
71
+ connectionId: request.connectionId,
72
+ pairId: request.pairId,
73
+ targetDeviceId: request.targetDeviceId,
74
+ expiresAt: this.now() + this.challengeTtlMs,
75
+ };
76
+ this.challenges.set(challenge.challengeId, challenge);
77
+ return challenge;
78
+ }
79
+ issuePairingTicket(challengeId, signature) {
80
+ const challenge = this.consumeChallenge(challengeId, signature, "pair");
81
+ const device = this.state.devices[challenge.deviceId];
82
+ const claims = {
83
+ version: CONNECTION_TICKET_VERSION,
84
+ kind: "pairing",
85
+ ticketId: `${TICKET_ID_PREFIX}${randomUUID()}`,
86
+ backendId: challenge.backendId,
87
+ connectionId: challenge.connectionId,
88
+ deviceId: challenge.deviceId,
89
+ devicePublicKey: challenge.devicePublicKey,
90
+ ...(device ? { accountId: device.accountId } : {}),
91
+ pairId: challenge.pairId,
92
+ issuedAt: this.now(),
93
+ expiresAt: this.now() + this.ticketTtlMs,
94
+ };
95
+ return { ticket: this.signTicket(claims), claims };
96
+ }
97
+ issueConnectionTicket(challengeId, signature) {
98
+ const challenge = this.consumeChallenge(challengeId, signature, "connect");
99
+ const device = this.requireActiveDevice(challenge.deviceId);
100
+ if (this.state.backendOwners[challenge.backendId] !== device.accountId) {
101
+ throw new Error("Device account is not authorized for this backend");
102
+ }
103
+ const claims = {
104
+ version: CONNECTION_TICKET_VERSION,
105
+ kind: "connection",
106
+ ticketId: `${TICKET_ID_PREFIX}${randomUUID()}`,
107
+ backendId: challenge.backendId,
108
+ connectionId: challenge.connectionId,
109
+ deviceId: device.deviceId,
110
+ devicePublicKey: device.publicKey,
111
+ accountId: device.accountId,
112
+ issuedAt: this.now(),
113
+ expiresAt: this.now() + this.ticketTtlMs,
114
+ };
115
+ return { ticket: this.signTicket(claims), claims };
116
+ }
117
+ verifyTicket(ticket) {
118
+ return verifyConnectionTicket(ticket, this.ticketPublicKey, this.now());
119
+ }
120
+ consumeRouteTicket(ticket) {
121
+ const claims = this.verifyTicket(ticket);
122
+ this.prune();
123
+ if (this.state.usedTickets[claims.ticketId] !== undefined)
124
+ throw new Error("Connection ticket was already used");
125
+ this.state.usedTickets[claims.ticketId] = claims.expiresAt;
126
+ this.persist();
127
+ return claims;
128
+ }
129
+ confirmPairing(claims) {
130
+ if (claims.kind !== "pairing" || !claims.pairId)
131
+ throw new Error("Connection is not a pairing attempt");
132
+ const prior = this.state.pairingCompletions[claims.pairId];
133
+ if (prior) {
134
+ if (prior.ticketId !== claims.ticketId)
135
+ throw new Error("Pairing capability was already used");
136
+ return { accountId: prior.accountId, deviceId: prior.deviceId, backendId: prior.backendId };
137
+ }
138
+ const currentOwner = this.state.backendOwners[claims.backendId];
139
+ let accountId = claims.accountId ?? currentOwner;
140
+ if (claims.accountId && currentOwner && claims.accountId !== currentOwner) {
141
+ throw new Error("Backend is owned by another account");
142
+ }
143
+ const existingDevice = this.state.devices[claims.deviceId];
144
+ if (existingDevice?.revokedAt)
145
+ throw new Error("Device is revoked");
146
+ if (existingDevice && existingDevice.publicKey !== claims.devicePublicKey)
147
+ throw new Error("Device public key mismatch");
148
+ if (existingDevice && claims.accountId !== existingDevice.accountId)
149
+ throw new Error("Device account mismatch");
150
+ if (!accountId)
151
+ accountId = `${ACCOUNT_ID_PREFIX}${randomUUID()}`;
152
+ if (existingDevice && existingDevice.accountId !== accountId)
153
+ throw new Error("Device belongs to another account");
154
+ const account = this.state.accounts[accountId] ?? {
155
+ accountId,
156
+ deviceIds: [],
157
+ backendIds: [],
158
+ createdAt: this.now(),
159
+ };
160
+ if (!account.deviceIds.includes(claims.deviceId))
161
+ account.deviceIds.push(claims.deviceId);
162
+ if (!account.backendIds.includes(claims.backendId))
163
+ account.backendIds.push(claims.backendId);
164
+ this.state.accounts[accountId] = account;
165
+ this.state.devices[claims.deviceId] = existingDevice ?? {
166
+ deviceId: claims.deviceId,
167
+ publicKey: claims.devicePublicKey,
168
+ accountId,
169
+ createdAt: this.now(),
170
+ };
171
+ this.state.backendOwners[claims.backendId] = accountId;
172
+ delete this.state.backendRevocations[claims.backendId];
173
+ this.state.pairingCompletions[claims.pairId] = {
174
+ pairId: claims.pairId,
175
+ ticketId: claims.ticketId,
176
+ accountId,
177
+ deviceId: claims.deviceId,
178
+ backendId: claims.backendId,
179
+ completedAt: this.now(),
180
+ };
181
+ this.persist();
182
+ return { accountId, deviceId: claims.deviceId, backendId: claims.backendId };
183
+ }
184
+ listAuthorizedBackendIds(challengeId, signature) {
185
+ const challenge = this.consumeChallenge(challengeId, signature, "discover");
186
+ const device = this.requireActiveDevice(challenge.deviceId);
187
+ return [...this.state.accounts[device.accountId].backendIds];
188
+ }
189
+ revokeDevice(challengeId, signature) {
190
+ const challenge = this.consumeChallenge(challengeId, signature, "revoke_device");
191
+ const actor = this.requireActiveDevice(challenge.deviceId);
192
+ const target = this.state.devices[challenge.targetDeviceId];
193
+ if (!target || target.accountId !== actor.accountId)
194
+ throw new Error("Target device does not belong to this account");
195
+ target.revokedAt = this.now();
196
+ this.persist();
197
+ return { accountId: actor.accountId, deviceId: target.deviceId };
198
+ }
199
+ revokeBackend(challengeId, signature) {
200
+ const challenge = this.consumeChallenge(challengeId, signature, "revoke_backend");
201
+ const actor = this.requireActiveDevice(challenge.deviceId);
202
+ if (this.state.backendOwners[challenge.backendId] !== actor.accountId) {
203
+ throw new Error("Backend does not belong to this account");
204
+ }
205
+ delete this.state.backendOwners[challenge.backendId];
206
+ this.state.backendRevocations[challenge.backendId] = { accountId: actor.accountId, revokedAt: this.now() };
207
+ const account = this.state.accounts[actor.accountId];
208
+ account.backendIds = account.backendIds.filter((backendId) => backendId !== challenge.backendId);
209
+ this.persist();
210
+ return { accountId: actor.accountId, backendId: challenge.backendId };
211
+ }
212
+ isDeviceActive(deviceId) {
213
+ const device = this.state.devices[deviceId];
214
+ return !!device && device.revokedAt === undefined;
215
+ }
216
+ getBackendOwner(backendId) {
217
+ return this.state.backendOwners[backendId];
218
+ }
219
+ getPendingBackendRevocation(backendId) {
220
+ const revocation = this.state.backendRevocations[backendId];
221
+ return revocation ? { accountId: revocation.accountId, backendId } : undefined;
222
+ }
223
+ consumeChallenge(challengeId, signature, expectedPurpose) {
224
+ const challenge = this.challenges.get(challengeId);
225
+ this.challenges.delete(challengeId);
226
+ if (!challenge || challenge.expiresAt <= this.now())
227
+ throw new Error("Device challenge is missing or expired");
228
+ if (challenge.purpose !== expectedPurpose)
229
+ throw new Error("Device challenge has the wrong purpose");
230
+ if (!verifyDeviceSignature(challenge.devicePublicKey, deviceChallengePayload(challenge), signature)) {
231
+ throw new Error("Invalid device challenge signature");
232
+ }
233
+ return challenge;
234
+ }
235
+ resolveChallengeIdentity(request) {
236
+ if (request.deviceId) {
237
+ const device = this.state.devices[request.deviceId];
238
+ if (device) {
239
+ if (device.revokedAt !== undefined)
240
+ throw new Error("Device is unknown or revoked");
241
+ if (request.devicePublicKey && request.devicePublicKey !== device.publicKey)
242
+ throw new Error("Device public key mismatch");
243
+ return { deviceId: device.deviceId, publicKey: device.publicKey };
244
+ }
245
+ if (request.purpose === "pair" && request.devicePublicKey && deriveDeviceId(request.devicePublicKey) === request.deviceId) {
246
+ return { deviceId: request.deviceId, publicKey: request.devicePublicKey };
247
+ }
248
+ throw new Error("Device is unknown or revoked");
249
+ }
250
+ if (request.purpose !== "pair" || !request.devicePublicKey) {
251
+ throw new Error("An existing device is required for this challenge");
252
+ }
253
+ return { deviceId: deriveDeviceId(request.devicePublicKey), publicKey: request.devicePublicKey };
254
+ }
255
+ validateChallengeContext(request) {
256
+ if (request.purpose !== "pair"
257
+ && request.purpose !== "connect"
258
+ && request.purpose !== "discover"
259
+ && request.purpose !== "revoke_device"
260
+ && request.purpose !== "revoke_backend") {
261
+ throw new Error("Unsupported device challenge purpose");
262
+ }
263
+ if (request.purpose !== "discover" && !request.backendId)
264
+ throw new Error("backendId is required");
265
+ if (request.purpose === "pair" || request.purpose === "connect") {
266
+ if (!request.connectionId)
267
+ throw new Error("connectionId is required");
268
+ }
269
+ if (request.purpose === "pair" && !request.pairId)
270
+ throw new Error("pairId is required");
271
+ if (request.purpose === "revoke_device" && !request.targetDeviceId)
272
+ throw new Error("targetDeviceId is required");
273
+ }
274
+ requireActiveDevice(deviceId) {
275
+ const device = this.state.devices[deviceId];
276
+ if (!device || device.revokedAt !== undefined)
277
+ throw new Error("Device is unknown or revoked");
278
+ return device;
279
+ }
280
+ signTicket(claims) {
281
+ const encodedClaims = Buffer.from(JSON.stringify(claims)).toString("base64url");
282
+ const signature = sign("sha256", Buffer.from(connectionTicketSignaturePayload(encodedClaims)), {
283
+ key: this.signing.privateKey,
284
+ dsaEncoding: "ieee-p1363",
285
+ }).toString("base64url");
286
+ return `${encodedClaims}.${signature}`;
287
+ }
288
+ prune() {
289
+ const now = this.now();
290
+ for (const [challengeId, challenge] of this.challenges) {
291
+ if (challenge.expiresAt <= now)
292
+ this.challenges.delete(challengeId);
293
+ }
294
+ let changed = false;
295
+ for (const [ticketId, expiresAt] of Object.entries(this.state.usedTickets)) {
296
+ if (expiresAt > now)
297
+ continue;
298
+ delete this.state.usedTickets[ticketId];
299
+ changed = true;
300
+ }
301
+ if (changed)
302
+ this.persist();
303
+ }
304
+ persist() {
305
+ const temporaryPath = `${this.statePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
306
+ writeFileSync(temporaryPath, `${JSON.stringify(this.state, null, 2)}\n`, { mode: 0o600 });
307
+ renameSync(temporaryPath, this.statePath);
308
+ chmodSync(this.statePath, 0o600);
309
+ }
310
+ }
311
+ function loadOrCreateSigningIdentity(filePath) {
312
+ let stored;
313
+ try {
314
+ stored = JSON.parse(readFileSync(filePath, "utf8"));
315
+ }
316
+ catch (error) {
317
+ if (error?.code !== "ENOENT")
318
+ throw new Error(`Invalid rendezvous signing identity: ${error instanceof Error ? error.message : String(error)}`);
319
+ }
320
+ if (!stored) {
321
+ const { privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
322
+ stored = {
323
+ version: SIGNING_IDENTITY_VERSION,
324
+ algorithm: "ES256",
325
+ privateKey: Buffer.from(privateKey.export({ type: "pkcs8", format: "der" })).toString("base64url"),
326
+ };
327
+ writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600, flag: "wx" });
328
+ }
329
+ if (stored.version !== SIGNING_IDENTITY_VERSION || stored.algorithm !== "ES256" || typeof stored.privateKey !== "string") {
330
+ throw new Error("Invalid rendezvous signing identity");
331
+ }
332
+ chmodSync(filePath, 0o600);
333
+ const privateKey = createPrivateKey({ key: Buffer.from(stored.privateKey, "base64url"), format: "der", type: "pkcs8" });
334
+ assertP256Key(privateKey);
335
+ const publicKeyObject = createPublicKey(privateKey.export({ type: "pkcs8", format: "pem" }));
336
+ assertP256Key(publicKeyObject);
337
+ return {
338
+ privateKey,
339
+ publicKey: Buffer.from(publicKeyObject.export({ type: "spki", format: "der" })).toString("base64url"),
340
+ };
341
+ }
342
+ function loadTrustState(filePath) {
343
+ try {
344
+ const value = JSON.parse(readFileSync(filePath, "utf8"));
345
+ if (!isTrustState(value))
346
+ throw new Error("unsupported or malformed state");
347
+ value.backendRevocations ??= {};
348
+ if (!hasConsistentReferences(value))
349
+ throw new Error("unsupported or malformed state");
350
+ return value;
351
+ }
352
+ catch (error) {
353
+ if (error?.code !== "ENOENT")
354
+ throw new Error(`Invalid rendezvous trust store: ${error instanceof Error ? error.message : String(error)}`);
355
+ return {
356
+ version: STORE_VERSION,
357
+ accounts: {},
358
+ devices: {},
359
+ backendOwners: {},
360
+ backendRevocations: {},
361
+ pairingCompletions: {},
362
+ usedTickets: {},
363
+ };
364
+ }
365
+ }
366
+ function isTrustState(value) {
367
+ if (!isRecord(value) || value.version !== STORE_VERSION)
368
+ return false;
369
+ return isRecordOf(value.accounts, (account, key) => isRecord(account)
370
+ && account.accountId === key
371
+ && isStringArray(account.deviceIds)
372
+ && isStringArray(account.backendIds)
373
+ && isNonNegativeInteger(account.createdAt))
374
+ && isRecordOf(value.devices, (device, key) => isRecord(device)
375
+ && device.deviceId === key
376
+ && isNonEmptyString(device.publicKey)
377
+ && isNonEmptyString(device.accountId)
378
+ && isNonNegativeInteger(device.createdAt)
379
+ && (device.revokedAt === undefined || isNonNegativeInteger(device.revokedAt)))
380
+ && isRecordOf(value.backendOwners, (accountId) => isNonEmptyString(accountId))
381
+ && (value.backendRevocations === undefined || isRecordOf(value.backendRevocations, (revocation) => isRecord(revocation)
382
+ && isNonEmptyString(revocation.accountId)
383
+ && isNonNegativeInteger(revocation.revokedAt)))
384
+ && isRecordOf(value.pairingCompletions, (completion, key) => isRecord(completion)
385
+ && completion.pairId === key
386
+ && isNonEmptyString(completion.ticketId)
387
+ && isNonEmptyString(completion.accountId)
388
+ && isNonEmptyString(completion.deviceId)
389
+ && isNonEmptyString(completion.backendId)
390
+ && isNonNegativeInteger(completion.completedAt))
391
+ && isRecordOf(value.usedTickets, (expiresAt) => isNonNegativeInteger(expiresAt));
392
+ }
393
+ function hasConsistentReferences(state) {
394
+ try {
395
+ for (const device of Object.values(state.devices)) {
396
+ if (deriveDeviceId(device.publicKey) !== device.deviceId)
397
+ return false;
398
+ const account = state.accounts[device.accountId];
399
+ if (!account?.deviceIds.includes(device.deviceId))
400
+ return false;
401
+ }
402
+ for (const account of Object.values(state.accounts)) {
403
+ if (account.deviceIds.some((deviceId) => state.devices[deviceId]?.accountId !== account.accountId))
404
+ return false;
405
+ if (account.backendIds.some((backendId) => state.backendOwners[backendId] !== account.accountId))
406
+ return false;
407
+ }
408
+ for (const [backendId, accountId] of Object.entries(state.backendOwners)) {
409
+ if (!state.accounts[accountId]?.backendIds.includes(backendId))
410
+ return false;
411
+ }
412
+ return true;
413
+ }
414
+ catch {
415
+ return false;
416
+ }
417
+ }
418
+ function isRecord(value) {
419
+ return !!value && typeof value === "object" && !Array.isArray(value);
420
+ }
421
+ function isRecordOf(value, predicate) {
422
+ return isRecord(value) && Object.entries(value).every(([key, entry]) => predicate(entry, key));
423
+ }
424
+ function isStringArray(value) {
425
+ return Array.isArray(value) && value.every(isNonEmptyString);
426
+ }
427
+ function isNonEmptyString(value) {
428
+ return typeof value === "string" && value.length > 0;
429
+ }
430
+ function isNonNegativeInteger(value) {
431
+ return Number.isSafeInteger(value) && value >= 0;
432
+ }
@@ -0,0 +1,61 @@
1
+ const AUTH_COOKIE_NAME = "pipane_auth";
2
+ export class AuthGuard {
3
+ token;
4
+ disableLocalBypass;
5
+ secureCookie;
6
+ constructor(options) {
7
+ this.token = options.token;
8
+ this.disableLocalBypass = options.disableLocalBypass ?? false;
9
+ this.secureCookie = options.secureCookie ?? false;
10
+ }
11
+ parseCookies(header) {
12
+ const cookies = {};
13
+ if (!header)
14
+ return cookies;
15
+ for (const part of header.split(";")) {
16
+ const separator = part.indexOf("=");
17
+ if (separator <= 0)
18
+ continue;
19
+ const key = part.slice(0, separator).trim();
20
+ const value = part.slice(separator + 1).trim();
21
+ cookies[key] = decodeURIComponent(value);
22
+ }
23
+ return cookies;
24
+ }
25
+ isLocalRequest(req) {
26
+ if (this.disableLocalBypass)
27
+ return false;
28
+ const address = req.socket.remoteAddress;
29
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
30
+ }
31
+ setAuthCookie(res) {
32
+ const secure = this.secureCookie ? "; Secure" : "";
33
+ const maxAgeSeconds = 60 * 60 * 24 * 30;
34
+ res.setHeader("Set-Cookie", `${AUTH_COOKIE_NAME}=${encodeURIComponent(this.token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}${secure}`);
35
+ }
36
+ isAuthorizedRequest(req) {
37
+ if (this.isLocalRequest(req))
38
+ return true;
39
+ return this.parseCookies(req.headers.cookie)[AUTH_COOKIE_NAME] === this.token;
40
+ }
41
+ register(app) {
42
+ app.get("/auth", (req, res) => {
43
+ const token = typeof req.query.token === "string" ? req.query.token : undefined;
44
+ if (this.isLocalRequest(req) || token === this.token) {
45
+ this.setAuthCookie(res);
46
+ res.redirect("/");
47
+ return;
48
+ }
49
+ res.status(401).type("html").send("<h3>Unauthorized</h3><p>Invalid auth token.</p>");
50
+ });
51
+ app.use((req, res, next) => {
52
+ if (this.isAuthorizedRequest(req)) {
53
+ if (this.isLocalRequest(req))
54
+ this.setAuthCookie(res);
55
+ next();
56
+ return;
57
+ }
58
+ res.status(401).type("html").send("<h3>Unauthorized</h3><p>Open the one-time auth URL shown in the pipane terminal.</p>");
59
+ });
60
+ }
61
+ }
@@ -0,0 +1,29 @@
1
+ export class BackendConnectionAuthorizer {
2
+ trustStore;
3
+ signaling;
4
+ constructor(trustStore, signaling) {
5
+ this.trustStore = trustStore;
6
+ this.signaling = signaling;
7
+ }
8
+ async authorize(context) {
9
+ const { claims } = context;
10
+ this.trustStore.authorizeTicket(claims);
11
+ let accountId;
12
+ if (claims.kind === "pairing") {
13
+ if (!claims.pairId || !context.pairingSecret)
14
+ throw new Error("Pairing secret is required");
15
+ this.trustStore.consumePairing(claims.pairId, context.pairingSecret);
16
+ const confirmation = await this.signaling.confirmPairing(claims.connectionId);
17
+ if (confirmation.pairId !== claims.pairId || confirmation.deviceId !== claims.deviceId) {
18
+ throw new Error("Rendezvous pairing confirmation does not match the connection ticket");
19
+ }
20
+ this.trustStore.completePairing(confirmation.accountId);
21
+ accountId = confirmation.accountId;
22
+ }
23
+ else {
24
+ accountId = claims.accountId;
25
+ }
26
+ this.trustStore.markTicketUsed(claims);
27
+ return { accountId, deviceId: claims.deviceId };
28
+ }
29
+ }