pipane 0.1.6 → 0.1.7

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 (55) 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/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
  5. package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
  6. package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
  7. package/dist/client/assets/index-DwbBcYUf.js +2 -0
  8. package/dist/client/assets/index-iblQYBAl.css +1 -0
  9. package/dist/client/assets/main-LGItV9Aj.js +2807 -0
  10. package/dist/client/assets/pairing-page-D-WxlWZR.js +1 -0
  11. package/dist/client/assets/remote-backend-manager-BWuGh30I.js +1 -0
  12. package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
  13. package/dist/client/assets/webrtc-frame-transport-BijNnA7N.js +1 -0
  14. package/dist/client/assets/ws-agent-adapter-BRysf-Y7.js +6 -0
  15. package/dist/client/index.html +2 -2
  16. package/dist/server/rendezvous/rendezvous-hub.js +426 -0
  17. package/dist/server/rendezvous/server.js +247 -0
  18. package/dist/server/rendezvous/trust-store.js +432 -0
  19. package/dist/server/server/auth-guard.js +61 -0
  20. package/dist/server/server/backend-connection-authorizer.js +29 -0
  21. package/dist/server/server/backend-identity.js +167 -0
  22. package/dist/server/server/backend-protocol-handler.js +132 -0
  23. package/dist/server/server/backend-trust-store.js +157 -0
  24. package/dist/server/server/backend-webrtc.js +239 -0
  25. package/dist/server/server/conversation-file-access.js +97 -0
  26. package/dist/server/server/frame-connection.js +48 -0
  27. package/dist/server/server/frame-router.js +45 -0
  28. package/dist/server/server/ice-servers.js +24 -0
  29. package/dist/server/server/local-backend-api.js +389 -0
  30. package/dist/server/server/local-settings.js +17 -4
  31. package/dist/server/server/pi-rpc-protocol.js +407 -0
  32. package/dist/server/server/process-pool.js +27 -24
  33. package/dist/server/server/rendezvous-client.js +282 -0
  34. package/dist/server/server/rest-api.js +133 -176
  35. package/dist/server/server/server.js +163 -97
  36. package/dist/server/server/session-index.js +105 -28
  37. package/dist/server/server/session-jsonl.js +82 -5
  38. package/dist/server/server/session-path.js +145 -0
  39. package/dist/server/server/update-api.js +28 -0
  40. package/dist/server/server/update-check.js +33 -13
  41. package/dist/server/server/update-manager.js +231 -0
  42. package/dist/server/server/worktree-name.js +116 -31
  43. package/dist/server/server/ws-handler.js +267 -186
  44. package/dist/server/shared/backend-api.js +1 -0
  45. package/dist/server/shared/backend-protocol.js +164 -0
  46. package/dist/server/shared/node-trust-crypto.js +61 -0
  47. package/dist/server/shared/rendezvous-protocol.js +243 -0
  48. package/dist/server/shared/tool-runtime.js +1 -0
  49. package/dist/server/shared/trust-protocol.js +97 -0
  50. package/dist/server/shared/updates.js +4 -0
  51. package/dist/server/shared/ws-protocol.js +473 -1
  52. package/docs/protocol.md +127 -0
  53. package/package.json +21 -8
  54. package/dist/client/assets/index-Dl_wdLZH.css +0 -1
  55. package/dist/client/assets/index-hNqbnG06.js +0 -2482
@@ -0,0 +1 @@
1
+ export const MAX_UPLOAD_FILE_BYTES = 20 * 1024 * 1024;
@@ -0,0 +1,164 @@
1
+ /** Semantic hidden-backend request protocol carried beside application v1 frames. */
2
+ export const BACKEND_PROTOCOL_VERSION = 2;
3
+ export const BACKEND_PROTOCOL_MAX_FRAME_BYTES = 3 * 1024 * 1024;
4
+ export function encodeBackendFrame(frame) {
5
+ return JSON.stringify(frame);
6
+ }
7
+ export function decodeBackendRequest(raw) {
8
+ const envelope = parseEnvelope(raw);
9
+ if (!envelope.ok)
10
+ return envelope;
11
+ const value = envelope.value;
12
+ const requestId = typeof value.id === "string" ? value.id : null;
13
+ const method = typeof value.method === "string" ? value.method : "";
14
+ if (value.kind !== "request" || !isNonEmptyString(value.id) || !isNonEmptyString(value.method) || !isRecord(value.params)) {
15
+ return invalid("invalid_request", "Backend request envelope is malformed", requestId, method);
16
+ }
17
+ if (!isBackendMethod(value.method)) {
18
+ return invalid("unknown_method", `Unknown backend method: ${value.method}`, value.id, value.method);
19
+ }
20
+ if (!validateParams(value.method, value.params)) {
21
+ return invalid("invalid_request", `Invalid parameters for ${value.method}`, value.id, value.method);
22
+ }
23
+ return { ok: true, value: value };
24
+ }
25
+ export function decodeBackendResponse(raw) {
26
+ const envelope = parseEnvelope(raw);
27
+ if (!envelope.ok)
28
+ return envelope;
29
+ const value = envelope.value;
30
+ const requestId = typeof value.id === "string" ? value.id : null;
31
+ const method = typeof value.method === "string" ? value.method : "";
32
+ if (value.kind !== "response" || !isNonEmptyString(value.id) || !isNonEmptyString(value.method) || typeof value.success !== "boolean") {
33
+ return invalid("invalid_request", "Backend response envelope is malformed", requestId, method);
34
+ }
35
+ if (value.success) {
36
+ if (!("result" in value))
37
+ return invalid("invalid_request", "Successful response is missing result", value.id, value.method);
38
+ }
39
+ else if (!isApiError(value.error)) {
40
+ return invalid("invalid_request", "Failed response is missing a valid error", value.id, value.method);
41
+ }
42
+ return { ok: true, value: value };
43
+ }
44
+ export function isBackendProtocolFrame(raw) {
45
+ // `encodeBackendFrame` emits the version first. Keep v1 streaming frames on
46
+ // their hot path without parsing large snapshots twice.
47
+ const prefix = raw.slice(0, 128);
48
+ return /"v"\s*:\s*2(?:\s*[,}])/u.test(prefix)
49
+ && /"kind"\s*:\s*"(?:request|response|event)"/u.test(prefix);
50
+ }
51
+ function parseEnvelope(raw) {
52
+ if (new TextEncoder().encode(raw).byteLength > BACKEND_PROTOCOL_MAX_FRAME_BYTES) {
53
+ return invalid("invalid_request", "Backend frame exceeds the size limit", null, "");
54
+ }
55
+ let value;
56
+ try {
57
+ value = JSON.parse(raw);
58
+ }
59
+ catch {
60
+ return invalid("invalid_json", "Backend frame is not valid JSON", null, "");
61
+ }
62
+ if (!isRecord(value))
63
+ return invalid("invalid_request", "Backend frame must be an object", null, "");
64
+ if (value.v !== BACKEND_PROTOCOL_VERSION) {
65
+ return invalid("unsupported_version", `Unsupported backend protocol version: ${String(value.v)}`, null, "");
66
+ }
67
+ return { ok: true, value };
68
+ }
69
+ function validateParams(method, params) {
70
+ switch (method) {
71
+ case "backend.capabilities":
72
+ case "sessions.list":
73
+ case "settings.get":
74
+ case "updates.get":
75
+ return hasOnlyKeys(params, []);
76
+ case "sessions.delete":
77
+ case "sessions.forkMessages":
78
+ case "sessions.raw":
79
+ return hasOnlyKeys(params, ["sessionPath"]) && isNonEmptyString(params.sessionPath);
80
+ case "files.read":
81
+ return hasOnlyKeys(params, ["sessionPath", "path"])
82
+ && isNonEmptyString(params.sessionPath)
83
+ && isNonEmptyString(params.path);
84
+ case "files.upload.create":
85
+ return hasOnlyKeys(params, ["fileName", "mimeType", "size"])
86
+ && isNonEmptyString(params.fileName)
87
+ && isNonEmptyString(params.mimeType)
88
+ && Number.isSafeInteger(params.size)
89
+ && params.size >= 0;
90
+ case "files.upload.append":
91
+ return hasOnlyKeys(params, ["uploadId", "offset", "data"])
92
+ && isNonEmptyString(params.uploadId)
93
+ && Number.isSafeInteger(params.offset)
94
+ && params.offset >= 0
95
+ && isNonEmptyString(params.data);
96
+ case "files.upload.complete":
97
+ return hasOnlyKeys(params, ["uploadId"]) && isNonEmptyString(params.uploadId);
98
+ case "host.browse":
99
+ return hasOnlyKeys(params, ["path"]) && typeof params.path === "string";
100
+ case "host.mkdir":
101
+ return hasOnlyKeys(params, ["parentPath", "name"])
102
+ && isNonEmptyString(params.parentPath)
103
+ && isNonEmptyString(params.name);
104
+ case "settings.validate":
105
+ case "settings.save":
106
+ return hasOnlyKeys(params, ["content"]) && typeof params.content === "string";
107
+ case "settings.patch":
108
+ return hasOnlyKeys(params, ["patch"]) && isRecord(params.patch);
109
+ case "updates.run":
110
+ return hasOnlyKeys(params, ["target"])
111
+ && (params.target === "pipane" || params.target === "pi" || params.target === "extensions");
112
+ }
113
+ }
114
+ function isBackendMethod(value) {
115
+ return [
116
+ "backend.capabilities",
117
+ "sessions.list",
118
+ "sessions.delete",
119
+ "sessions.forkMessages",
120
+ "sessions.raw",
121
+ "files.read",
122
+ "files.upload.create",
123
+ "files.upload.append",
124
+ "files.upload.complete",
125
+ "host.browse",
126
+ "host.mkdir",
127
+ "settings.get",
128
+ "settings.validate",
129
+ "settings.patch",
130
+ "settings.save",
131
+ "updates.get",
132
+ "updates.run",
133
+ ].includes(value);
134
+ }
135
+ function isApiError(value) {
136
+ return isRecord(value)
137
+ && isBackendErrorCode(value.code)
138
+ && isNonEmptyString(value.message);
139
+ }
140
+ function isBackendErrorCode(value) {
141
+ return typeof value === "string" && [
142
+ "invalid_json",
143
+ "invalid_request",
144
+ "unsupported_version",
145
+ "unknown_method",
146
+ "not_found",
147
+ "forbidden",
148
+ "conflict",
149
+ "internal_error",
150
+ ].includes(value);
151
+ }
152
+ function invalid(code, message, requestId, method) {
153
+ return { ok: false, error: { code, message }, requestId, method };
154
+ }
155
+ function hasOnlyKeys(value, keys) {
156
+ const actual = Object.keys(value);
157
+ return actual.length === keys.length && actual.every((key) => keys.includes(key));
158
+ }
159
+ function isRecord(value) {
160
+ return typeof value === "object" && value !== null && !Array.isArray(value);
161
+ }
162
+ function isNonEmptyString(value) {
163
+ return typeof value === "string" && value.length > 0;
164
+ }
@@ -0,0 +1,61 @@
1
+ import { createHash, createPublicKey, verify } from "node:crypto";
2
+ import { connectionTicketSignaturePayload, parseConnectionTicketClaims, } from "./trust-protocol.js";
3
+ const DEVICE_ID_PREFIX = "d_";
4
+ export function deriveDeviceId(publicKey) {
5
+ const bytes = decodeP256PublicKey(publicKey);
6
+ return `${DEVICE_ID_PREFIX}${createHash("sha256").update(bytes).digest("base64url")}`;
7
+ }
8
+ export function verifyDeviceSignature(publicKey, payload, signature) {
9
+ try {
10
+ const key = createPublicKey({ key: decodeP256PublicKey(publicKey), format: "der", type: "spki" });
11
+ return verify("sha256", Buffer.from(payload), { key, dsaEncoding: "ieee-p1363" }, Buffer.from(signature, "base64url"));
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ export function verifyConnectionTicket(ticket, publicKey, now = Date.now()) {
18
+ const parts = ticket.split(".");
19
+ if (parts.length !== 2 || !parts[0] || !parts[1])
20
+ throw new Error("Malformed connection ticket");
21
+ const [encodedClaims, encodedSignature] = parts;
22
+ let claims;
23
+ try {
24
+ const bytes = Buffer.from(encodedClaims, "base64url");
25
+ if (bytes.toString("base64url") !== encodedClaims)
26
+ throw new Error("non-canonical payload");
27
+ claims = JSON.parse(bytes.toString("utf8"));
28
+ }
29
+ catch {
30
+ throw new Error("Malformed connection ticket claims");
31
+ }
32
+ const parsed = parseConnectionTicketClaims(claims);
33
+ if (!parsed)
34
+ throw new Error("Invalid connection ticket claims");
35
+ if (!verifyDeviceSignature(publicKey, connectionTicketSignaturePayload(encodedClaims), encodedSignature)) {
36
+ throw new Error("Invalid connection ticket signature");
37
+ }
38
+ if (parsed.expiresAt <= now)
39
+ throw new Error("Connection ticket expired");
40
+ if (parsed.issuedAt > now + 30_000)
41
+ throw new Error("Connection ticket was issued in the future");
42
+ return parsed;
43
+ }
44
+ export function decodeP256PublicKey(publicKey) {
45
+ if (!/^[A-Za-z0-9_-]+$/.test(publicKey))
46
+ throw new Error("Invalid public key encoding");
47
+ const bytes = Buffer.from(publicKey, "base64url");
48
+ if (bytes.length === 0 || bytes.toString("base64url") !== publicKey)
49
+ throw new Error("Invalid public key encoding");
50
+ const key = createPublicKey({ key: bytes, format: "der", type: "spki" });
51
+ assertP256Key(key);
52
+ const canonical = Buffer.from(key.export({ type: "spki", format: "der" }));
53
+ if (!canonical.equals(bytes))
54
+ throw new Error("Public key is not canonical");
55
+ return bytes;
56
+ }
57
+ export function assertP256Key(key) {
58
+ if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") {
59
+ throw new Error("Identity must use P-256");
60
+ }
61
+ }
@@ -0,0 +1,243 @@
1
+ export const RENDEZVOUS_PROTOCOL_VERSION = 2;
2
+ export function rendezvousWebSocketUrl(baseUrl, role) {
3
+ const url = new URL(baseUrl);
4
+ if (url.protocol === "http:")
5
+ url.protocol = "ws:";
6
+ else if (url.protocol === "https:")
7
+ url.protocol = "wss:";
8
+ else if (url.protocol !== "ws:" && url.protocol !== "wss:") {
9
+ throw new Error(`Unsupported rendezvous URL protocol: ${url.protocol}`);
10
+ }
11
+ url.pathname = `/v${RENDEZVOUS_PROTOCOL_VERSION}/rendezvous/${role}`;
12
+ url.search = "";
13
+ url.hash = "";
14
+ return url.toString();
15
+ }
16
+ export function backendRegistrationPayload(nonce) {
17
+ return `pipane-rendezvous-v${RENDEZVOUS_PROTOCOL_VERSION}\n${nonce}`;
18
+ }
19
+ export function encodeRendezvousMessage(message) {
20
+ return JSON.stringify(message);
21
+ }
22
+ export function decodeBackendCommand(raw) {
23
+ return decodeCommand(raw, "backend");
24
+ }
25
+ export function decodeBrowserCommand(raw) {
26
+ return decodeCommand(raw, "browser");
27
+ }
28
+ export function decodeBackendMessage(raw) {
29
+ return decodeServerMessage(raw, "backend");
30
+ }
31
+ export function decodeBrowserMessage(raw) {
32
+ return decodeServerMessage(raw, "browser");
33
+ }
34
+ function decodeCommand(raw, role) {
35
+ const envelope = parseEnvelope(raw);
36
+ if (!envelope.ok)
37
+ return envelope;
38
+ const value = envelope.value;
39
+ switch (value.type) {
40
+ case "register_backend":
41
+ if (role !== "backend")
42
+ return unknownMessage(value.type);
43
+ if (!isString(value.publicKey) || !isString(value.signature) || !isBackendMetadata(value.metadata)) {
44
+ return invalidMessage("register_backend requires publicKey, signature, and valid metadata");
45
+ }
46
+ break;
47
+ case "open_pairing":
48
+ if (role !== "backend")
49
+ return unknownMessage(value.type);
50
+ if (!isString(value.pairId) || !isPositiveInteger(value.expiresAt)) {
51
+ return invalidMessage("open_pairing requires pairId and expiresAt");
52
+ }
53
+ break;
54
+ case "confirm_pairing":
55
+ if (role !== "backend")
56
+ return unknownMessage(value.type);
57
+ if (!isString(value.connectionId))
58
+ return invalidMessage("confirm_pairing requires connectionId");
59
+ break;
60
+ case "connect_backend":
61
+ if (role !== "browser")
62
+ return unknownMessage(value.type);
63
+ if (!isString(value.backendId) || !isString(value.ticket)) {
64
+ return invalidMessage("connect_backend requires backendId and ticket");
65
+ }
66
+ break;
67
+ case "signal":
68
+ if (!isString(value.connectionId) || !isIceSignal(value.signal)) {
69
+ return invalidMessage("signal requires connectionId and a valid ICE signal");
70
+ }
71
+ break;
72
+ case "connection_binding":
73
+ if (role !== "backend")
74
+ return unknownMessage(value.type);
75
+ if (!isString(value.connectionId) || !isBackendIdentityBinding(value.binding)) {
76
+ return invalidMessage("connection_binding requires connectionId and a valid binding");
77
+ }
78
+ break;
79
+ case "close_connection":
80
+ if (!isString(value.connectionId) || !isOptionalString(value.reason)) {
81
+ return invalidMessage("close_connection requires connectionId and an optional reason");
82
+ }
83
+ break;
84
+ default:
85
+ return unknownMessage(value.type);
86
+ }
87
+ return { ok: true, value: value };
88
+ }
89
+ function decodeServerMessage(raw, role) {
90
+ const envelope = parseEnvelope(raw);
91
+ if (!envelope.ok)
92
+ return envelope;
93
+ const value = envelope.value;
94
+ switch (value.type) {
95
+ case "challenge":
96
+ if (role !== "backend" || !isString(value.nonce))
97
+ return invalidMessage("invalid backend challenge");
98
+ break;
99
+ case "registered":
100
+ if (role !== "backend" || !isString(value.backendId) || !isString(value.ticketPublicKey) || !isIceServers(value.iceServers)) {
101
+ return invalidMessage("invalid backend registration");
102
+ }
103
+ break;
104
+ case "pairing_opened":
105
+ if (role !== "backend" || !isString(value.pairId) || !isPositiveInteger(value.expiresAt)) {
106
+ return invalidMessage("invalid pairing acknowledgement");
107
+ }
108
+ break;
109
+ case "pairing_confirmed":
110
+ if (role !== "backend"
111
+ || !isString(value.connectionId)
112
+ || !isString(value.pairId)
113
+ || !isString(value.accountId)
114
+ || !isString(value.deviceId)) {
115
+ return invalidMessage("invalid pairing confirmation");
116
+ }
117
+ break;
118
+ case "connection_request":
119
+ if (role !== "backend" || !isString(value.connectionId) || !isString(value.ticket) || !isIceServers(value.iceServers)) {
120
+ return invalidMessage("invalid connection request");
121
+ }
122
+ break;
123
+ case "backend_connected":
124
+ if (role !== "browser" || !isString(value.backendId) || !isString(value.connectionId)) {
125
+ return invalidMessage("invalid browser connection acknowledgement");
126
+ }
127
+ break;
128
+ case "signal":
129
+ if (!isString(value.connectionId) || !isIceSignal(value.signal))
130
+ return invalidMessage("invalid ICE signal");
131
+ break;
132
+ case "connection_binding":
133
+ if (role !== "browser" || !isString(value.connectionId) || !isBackendIdentityBinding(value.binding)) {
134
+ return invalidMessage("invalid backend identity binding");
135
+ }
136
+ break;
137
+ case "connection_closed":
138
+ if (!isString(value.connectionId) || !isString(value.reason))
139
+ return invalidMessage("invalid connection closure");
140
+ break;
141
+ case "authorization_revoked":
142
+ if (role !== "backend" || !isString(value.accountId) || !isOptionalString(value.deviceId)) {
143
+ return invalidMessage("invalid authorization revocation");
144
+ }
145
+ break;
146
+ case "error":
147
+ if (!isErrorCode(value.code) || !isString(value.message) || !isOptionalString(value.connectionId)) {
148
+ return invalidMessage("invalid rendezvous error");
149
+ }
150
+ break;
151
+ default:
152
+ return unknownMessage(value.type);
153
+ }
154
+ return { ok: true, value: value };
155
+ }
156
+ function parseEnvelope(raw) {
157
+ let value;
158
+ try {
159
+ value = JSON.parse(raw);
160
+ }
161
+ catch {
162
+ return { ok: false, error: { code: "invalid_json", message: "Message is not valid JSON" } };
163
+ }
164
+ if (!isRecord(value) || !isString(value.type))
165
+ return invalidMessage("Message must be an object with a type");
166
+ if (value.protocolVersion !== RENDEZVOUS_PROTOCOL_VERSION) {
167
+ return {
168
+ ok: false,
169
+ error: {
170
+ code: "unsupported_version",
171
+ message: `Unsupported rendezvous protocol version: ${String(value.protocolVersion)}`,
172
+ },
173
+ };
174
+ }
175
+ return { ok: true, value };
176
+ }
177
+ function isBackendMetadata(value) {
178
+ return isRecord(value)
179
+ && isOptionalString(value.name)
180
+ && isString(value.softwareVersion)
181
+ && Array.isArray(value.protocolVersions)
182
+ && value.protocolVersions.length > 0
183
+ && value.protocolVersions.every((version) => Number.isSafeInteger(version) && version > 0);
184
+ }
185
+ function isIceSignal(value) {
186
+ if (!isRecord(value) || !isString(value.kind))
187
+ return false;
188
+ if (value.kind === "description") {
189
+ return (value.type === "offer" || value.type === "answer") && isString(value.sdp);
190
+ }
191
+ if (value.kind === "candidate") {
192
+ return isString(value.candidate)
193
+ && (value.sdpMid === null || isString(value.sdpMid))
194
+ && (value.sdpMLineIndex === null || (Number.isSafeInteger(value.sdpMLineIndex) && value.sdpMLineIndex >= 0));
195
+ }
196
+ return false;
197
+ }
198
+ function isBackendIdentityBinding(value) {
199
+ return isRecord(value)
200
+ && value.version === 1
201
+ && isString(value.backendId)
202
+ && isString(value.publicKey)
203
+ && isString(value.connectionId)
204
+ && isString(value.offerSha256)
205
+ && isString(value.answerSha256)
206
+ && isString(value.dtlsFingerprint)
207
+ && isPositiveInteger(value.expiresAt)
208
+ && isString(value.signature);
209
+ }
210
+ function isIceServers(value) {
211
+ return Array.isArray(value) && value.every((server) => isRecord(server)
212
+ && (isString(server.urls) || (Array.isArray(server.urls) && server.urls.length > 0 && server.urls.every(isString)))
213
+ && isOptionalString(server.username)
214
+ && isOptionalString(server.credential));
215
+ }
216
+ function isErrorCode(value) {
217
+ return value === "invalid_json"
218
+ || value === "invalid_message"
219
+ || value === "unsupported_version"
220
+ || value === "unknown_message"
221
+ || value === "backend_offline"
222
+ || value === "unauthorized_connection"
223
+ || value === "invalid_ticket"
224
+ || value === "invalid_pairing";
225
+ }
226
+ function invalidMessage(message) {
227
+ return { ok: false, error: { code: "invalid_message", message } };
228
+ }
229
+ function unknownMessage(type) {
230
+ return { ok: false, error: { code: "unknown_message", message: `Unknown rendezvous message: ${String(type)}` } };
231
+ }
232
+ function isRecord(value) {
233
+ return !!value && typeof value === "object" && !Array.isArray(value);
234
+ }
235
+ function isString(value) {
236
+ return typeof value === "string" && value.length > 0;
237
+ }
238
+ function isOptionalString(value) {
239
+ return value === undefined || isString(value);
240
+ }
241
+ function isPositiveInteger(value) {
242
+ return Number.isSafeInteger(value) && value > 0;
243
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,97 @@
1
+ export const TRUST_PROTOCOL_VERSION = 1;
2
+ export const CONNECTION_TICKET_VERSION = 1;
3
+ export const PIPANE_DATA_CHANNEL_LABEL = "pipane";
4
+ export const PIPANE_DATA_CHANNEL_PROTOCOL = "pipane.v1";
5
+ export function deviceChallengePayload(challenge) {
6
+ return [
7
+ `pipane-device-challenge-v${TRUST_PROTOCOL_VERSION}`,
8
+ challenge.challengeId,
9
+ challenge.nonce,
10
+ challenge.purpose,
11
+ challenge.deviceId,
12
+ challenge.devicePublicKey,
13
+ challenge.backendId ?? "",
14
+ challenge.connectionId ?? "",
15
+ challenge.pairId ?? "",
16
+ challenge.targetDeviceId ?? "",
17
+ String(challenge.expiresAt),
18
+ ].join("\n");
19
+ }
20
+ export function connectionTicketSignaturePayload(encodedClaims) {
21
+ return `pipane-connection-ticket-v${CONNECTION_TICKET_VERSION}\n${encodedClaims}`;
22
+ }
23
+ export function backendIdentityBindingPayload(binding) {
24
+ return [
25
+ `pipane-backend-binding-v${TRUST_PROTOCOL_VERSION}`,
26
+ binding.backendId,
27
+ binding.publicKey,
28
+ binding.connectionId,
29
+ binding.offerSha256,
30
+ binding.answerSha256,
31
+ binding.dtlsFingerprint,
32
+ String(binding.expiresAt),
33
+ ].join("\n");
34
+ }
35
+ export function deviceConnectionProofPayload(ticket, bindingSignature) {
36
+ return [
37
+ `pipane-device-connection-v${TRUST_PROTOCOL_VERSION}`,
38
+ ticket,
39
+ bindingSignature,
40
+ ].join("\n");
41
+ }
42
+ export function decodeDataChannelAuthenticateFrame(raw) {
43
+ let value;
44
+ try {
45
+ value = JSON.parse(raw);
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ if (!isRecord(value)
51
+ || value.protocolVersion !== TRUST_PROTOCOL_VERSION
52
+ || value.type !== "authenticate"
53
+ || !isNonEmptyString(value.ticket)
54
+ || !isNonEmptyString(value.bindingSignature)
55
+ || !isNonEmptyString(value.deviceSignature)
56
+ || !isOptionalString(value.pairingSecret)) {
57
+ return undefined;
58
+ }
59
+ return value;
60
+ }
61
+ export function parseConnectionTicketClaims(value) {
62
+ if (!isRecord(value)
63
+ || value.version !== CONNECTION_TICKET_VERSION
64
+ || (value.kind !== "pairing" && value.kind !== "connection")
65
+ || !isNonEmptyString(value.ticketId)
66
+ || !isNonEmptyString(value.backendId)
67
+ || !isNonEmptyString(value.connectionId)
68
+ || !isNonEmptyString(value.deviceId)
69
+ || !isNonEmptyString(value.devicePublicKey)
70
+ || !isOptionalString(value.accountId)
71
+ || !isOptionalString(value.pairId)
72
+ || !isNonNegativeInteger(value.issuedAt)
73
+ || !isPositiveInteger(value.expiresAt)
74
+ || value.expiresAt <= value.issuedAt) {
75
+ return undefined;
76
+ }
77
+ if (value.kind === "pairing" && !isNonEmptyString(value.pairId))
78
+ return undefined;
79
+ if (value.kind === "connection" && !isNonEmptyString(value.accountId))
80
+ return undefined;
81
+ return value;
82
+ }
83
+ function isRecord(value) {
84
+ return !!value && typeof value === "object" && !Array.isArray(value);
85
+ }
86
+ function isNonEmptyString(value) {
87
+ return typeof value === "string" && value.length > 0;
88
+ }
89
+ function isOptionalString(value) {
90
+ return value === undefined || isNonEmptyString(value);
91
+ }
92
+ function isNonNegativeInteger(value) {
93
+ return Number.isSafeInteger(value) && value >= 0;
94
+ }
95
+ function isPositiveInteger(value) {
96
+ return Number.isSafeInteger(value) && value > 0;
97
+ }
@@ -0,0 +1,4 @@
1
+ export const UPDATE_TARGETS = ["pipane", "pi", "extensions"];
2
+ export function isUpdateTarget(value) {
3
+ return typeof value === "string" && UPDATE_TARGETS.includes(value);
4
+ }