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.
- package/README.md +14 -3
- package/bin/pipane-rendezvous.js +21 -0
- package/bin/pipane.js +21 -1
- package/dist/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
- package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
- package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
- package/dist/client/assets/index-DwbBcYUf.js +2 -0
- package/dist/client/assets/index-iblQYBAl.css +1 -0
- package/dist/client/assets/main-LGItV9Aj.js +2807 -0
- package/dist/client/assets/pairing-page-D-WxlWZR.js +1 -0
- package/dist/client/assets/remote-backend-manager-BWuGh30I.js +1 -0
- package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
- package/dist/client/assets/webrtc-frame-transport-BijNnA7N.js +1 -0
- package/dist/client/assets/ws-agent-adapter-BRysf-Y7.js +6 -0
- package/dist/client/index.html +2 -2
- package/dist/server/rendezvous/rendezvous-hub.js +426 -0
- package/dist/server/rendezvous/server.js +247 -0
- package/dist/server/rendezvous/trust-store.js +432 -0
- package/dist/server/server/auth-guard.js +61 -0
- package/dist/server/server/backend-connection-authorizer.js +29 -0
- package/dist/server/server/backend-identity.js +167 -0
- package/dist/server/server/backend-protocol-handler.js +132 -0
- package/dist/server/server/backend-trust-store.js +157 -0
- package/dist/server/server/backend-webrtc.js +239 -0
- package/dist/server/server/conversation-file-access.js +97 -0
- package/dist/server/server/frame-connection.js +48 -0
- package/dist/server/server/frame-router.js +45 -0
- package/dist/server/server/ice-servers.js +24 -0
- package/dist/server/server/local-backend-api.js +389 -0
- package/dist/server/server/local-settings.js +17 -4
- package/dist/server/server/pi-rpc-protocol.js +407 -0
- package/dist/server/server/process-pool.js +27 -24
- package/dist/server/server/rendezvous-client.js +282 -0
- package/dist/server/server/rest-api.js +133 -176
- package/dist/server/server/server.js +163 -97
- package/dist/server/server/session-index.js +105 -28
- package/dist/server/server/session-jsonl.js +82 -5
- package/dist/server/server/session-path.js +145 -0
- package/dist/server/server/update-api.js +28 -0
- package/dist/server/server/update-check.js +33 -13
- package/dist/server/server/update-manager.js +231 -0
- package/dist/server/server/worktree-name.js +116 -31
- package/dist/server/server/ws-handler.js +267 -186
- package/dist/server/shared/backend-api.js +1 -0
- package/dist/server/shared/backend-protocol.js +164 -0
- package/dist/server/shared/node-trust-crypto.js +61 -0
- package/dist/server/shared/rendezvous-protocol.js +243 -0
- package/dist/server/shared/tool-runtime.js +1 -0
- package/dist/server/shared/trust-protocol.js +97 -0
- package/dist/server/shared/updates.js +4 -0
- package/dist/server/shared/ws-protocol.js +473 -1
- package/docs/protocol.md +127 -0
- package/package.json +21 -8
- package/dist/client/assets/index-Dl_wdLZH.css +0 -1
- package/dist/client/assets/index-hNqbnG06.js +0 -2482
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify, } from "node:crypto";
|
|
2
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { backendRegistrationPayload } from "../shared/rendezvous-protocol.js";
|
|
6
|
+
import { TRUST_PROTOCOL_VERSION, backendIdentityBindingPayload, } from "../shared/trust-protocol.js";
|
|
7
|
+
const BACKEND_IDENTITY_VERSION = 1;
|
|
8
|
+
const BACKEND_ID_PREFIX = "b_";
|
|
9
|
+
export function defaultBackendIdentityPath() {
|
|
10
|
+
const configDir = process.env.PIPANE_CONFIG_DIR || path.join(homedir(), ".config", "pipane");
|
|
11
|
+
return path.join(configDir, "backend-identity.json");
|
|
12
|
+
}
|
|
13
|
+
export function loadOrCreateBackendIdentity(filePath = defaultBackendIdentityPath()) {
|
|
14
|
+
try {
|
|
15
|
+
return loadBackendIdentity(filePath);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error?.code !== "ENOENT")
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
22
|
+
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
23
|
+
const privateKeyDer = privateKey.export({ type: "pkcs8", format: "der" });
|
|
24
|
+
const stored = {
|
|
25
|
+
version: BACKEND_IDENTITY_VERSION,
|
|
26
|
+
algorithm: "ES256",
|
|
27
|
+
privateKey: Buffer.from(privateKeyDer).toString("base64url"),
|
|
28
|
+
};
|
|
29
|
+
try {
|
|
30
|
+
writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, {
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
mode: 0o600,
|
|
33
|
+
flag: "wx",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
// Another pipane process may have created the identity concurrently.
|
|
38
|
+
if (error?.code !== "EEXIST")
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
chmodSync(filePath, 0o600);
|
|
42
|
+
return loadBackendIdentity(filePath);
|
|
43
|
+
}
|
|
44
|
+
export function loadBackendIdentity(filePath) {
|
|
45
|
+
let stored;
|
|
46
|
+
try {
|
|
47
|
+
stored = JSON.parse(readFileSync(filePath, "utf8"));
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error?.code === "ENOENT")
|
|
51
|
+
throw error;
|
|
52
|
+
throw new Error(`Invalid backend identity file ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
53
|
+
}
|
|
54
|
+
if (!isStoredBackendIdentity(stored)) {
|
|
55
|
+
throw new Error(`Invalid backend identity file ${filePath}: unsupported or malformed identity`);
|
|
56
|
+
}
|
|
57
|
+
let privateKey;
|
|
58
|
+
try {
|
|
59
|
+
privateKey = createPrivateKey({
|
|
60
|
+
key: Buffer.from(stored.privateKey, "base64url"),
|
|
61
|
+
format: "der",
|
|
62
|
+
type: "pkcs8",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
throw new Error(`Invalid backend identity file ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
+
}
|
|
68
|
+
assertP256Key(privateKey);
|
|
69
|
+
const publicKey = exportPublicKey(privateKey);
|
|
70
|
+
return {
|
|
71
|
+
backendId: deriveBackendId(publicKey),
|
|
72
|
+
algorithm: "ES256",
|
|
73
|
+
publicKey,
|
|
74
|
+
privateKey,
|
|
75
|
+
filePath,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export function deriveBackendId(publicKey) {
|
|
79
|
+
const publicKeyBytes = decodePublicKey(publicKey);
|
|
80
|
+
const key = createPublicKey({ key: publicKeyBytes, format: "der", type: "spki" });
|
|
81
|
+
assertP256Key(key);
|
|
82
|
+
return `${BACKEND_ID_PREFIX}${createHash("sha256").update(publicKeyBytes).digest("base64url")}`;
|
|
83
|
+
}
|
|
84
|
+
export function signBackendChallenge(identity, nonce) {
|
|
85
|
+
const signature = sign("sha256", Buffer.from(backendRegistrationPayload(nonce)), {
|
|
86
|
+
key: identity.privateKey,
|
|
87
|
+
dsaEncoding: "ieee-p1363",
|
|
88
|
+
});
|
|
89
|
+
return signature.toString("base64url");
|
|
90
|
+
}
|
|
91
|
+
export function verifyBackendChallenge(publicKey, nonce, signature) {
|
|
92
|
+
return verifyBackendSignature(publicKey, backendRegistrationPayload(nonce), signature);
|
|
93
|
+
}
|
|
94
|
+
export function signBackendIdentityBinding(identity, input) {
|
|
95
|
+
const unsigned = {
|
|
96
|
+
version: TRUST_PROTOCOL_VERSION,
|
|
97
|
+
backendId: identity.backendId,
|
|
98
|
+
publicKey: identity.publicKey,
|
|
99
|
+
connectionId: input.connectionId,
|
|
100
|
+
offerSha256: sha256Base64Url(input.offerSdp),
|
|
101
|
+
answerSha256: sha256Base64Url(input.answerSdp),
|
|
102
|
+
dtlsFingerprint: extractDtlsFingerprint(input.answerSdp),
|
|
103
|
+
expiresAt: input.expiresAt,
|
|
104
|
+
};
|
|
105
|
+
const signature = sign("sha256", Buffer.from(backendIdentityBindingPayload(unsigned)), {
|
|
106
|
+
key: identity.privateKey,
|
|
107
|
+
dsaEncoding: "ieee-p1363",
|
|
108
|
+
}).toString("base64url");
|
|
109
|
+
return { ...unsigned, signature };
|
|
110
|
+
}
|
|
111
|
+
export function verifyBackendIdentityBinding(binding) {
|
|
112
|
+
try {
|
|
113
|
+
if (binding.version !== TRUST_PROTOCOL_VERSION || deriveBackendId(binding.publicKey) !== binding.backendId)
|
|
114
|
+
return false;
|
|
115
|
+
const { signature, ...unsigned } = binding;
|
|
116
|
+
return verifyBackendSignature(binding.publicKey, backendIdentityBindingPayload(unsigned), signature);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function sha256Base64Url(value) {
|
|
123
|
+
return createHash("sha256").update(value).digest("base64url");
|
|
124
|
+
}
|
|
125
|
+
export function extractDtlsFingerprint(sdp) {
|
|
126
|
+
const match = /^a=fingerprint:(sha-(?:1|224|256|384|512)) ([A-Fa-f0-9:]+)\r?$/m.exec(sdp);
|
|
127
|
+
if (!match)
|
|
128
|
+
throw new Error("SDP answer is missing a DTLS certificate fingerprint");
|
|
129
|
+
return `${match[1].toLowerCase()} ${match[2].toUpperCase()}`;
|
|
130
|
+
}
|
|
131
|
+
function verifyBackendSignature(publicKey, payload, signature) {
|
|
132
|
+
try {
|
|
133
|
+
const key = createPublicKey({ key: decodePublicKey(publicKey), format: "der", type: "spki" });
|
|
134
|
+
assertP256Key(key);
|
|
135
|
+
return verify("sha256", Buffer.from(payload), { key, dsaEncoding: "ieee-p1363" }, Buffer.from(signature, "base64url"));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function exportPublicKey(privateKey) {
|
|
142
|
+
const publicKey = createPublicKey(privateKey.export({ type: "pkcs8", format: "pem" }));
|
|
143
|
+
assertP256Key(publicKey);
|
|
144
|
+
return Buffer.from(publicKey.export({ type: "spki", format: "der" })).toString("base64url");
|
|
145
|
+
}
|
|
146
|
+
function decodePublicKey(publicKey) {
|
|
147
|
+
if (!/^[A-Za-z0-9_-]+$/.test(publicKey))
|
|
148
|
+
throw new Error("Invalid public key encoding");
|
|
149
|
+
const bytes = Buffer.from(publicKey, "base64url");
|
|
150
|
+
if (bytes.length === 0 || bytes.toString("base64url") !== publicKey)
|
|
151
|
+
throw new Error("Invalid public key encoding");
|
|
152
|
+
return bytes;
|
|
153
|
+
}
|
|
154
|
+
function assertP256Key(key) {
|
|
155
|
+
if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") {
|
|
156
|
+
throw new Error("Backend identity must use a P-256 key");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function isStoredBackendIdentity(value) {
|
|
160
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
161
|
+
return false;
|
|
162
|
+
const record = value;
|
|
163
|
+
return record.version === BACKEND_IDENTITY_VERSION
|
|
164
|
+
&& record.algorithm === "ES256"
|
|
165
|
+
&& typeof record.privateKey === "string"
|
|
166
|
+
&& record.privateKey.length > 0;
|
|
167
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { BACKEND_PROTOCOL_VERSION, decodeBackendRequest, encodeBackendFrame, } from "../shared/backend-protocol.js";
|
|
2
|
+
import { FRAME_CONNECTION_OPEN } from "./frame-connection.js";
|
|
3
|
+
import { LocalBackendApiError } from "./local-backend-api.js";
|
|
4
|
+
const MAX_COMPLETED_REQUESTS = 128;
|
|
5
|
+
const MAX_CACHED_RESPONSE_BYTES = 256 * 1024;
|
|
6
|
+
const MAX_ACTIVE_REQUESTS = 64;
|
|
7
|
+
/** Device-scoped idempotent semantic dispatcher shared across carrier reconnects. */
|
|
8
|
+
export class BackendProtocolHandler {
|
|
9
|
+
api;
|
|
10
|
+
completed = new Map();
|
|
11
|
+
active = new Map();
|
|
12
|
+
constructor(api) {
|
|
13
|
+
this.api = api;
|
|
14
|
+
}
|
|
15
|
+
accept(connection, deviceId = "anonymous") {
|
|
16
|
+
connection.on("message", (frame) => {
|
|
17
|
+
const raw = frame.toString();
|
|
18
|
+
const decoded = decodeBackendRequest(raw);
|
|
19
|
+
if (!decoded.ok) {
|
|
20
|
+
this.send(connection, {
|
|
21
|
+
v: BACKEND_PROTOCOL_VERSION,
|
|
22
|
+
kind: "response",
|
|
23
|
+
id: decoded.requestId ?? "invalid",
|
|
24
|
+
method: decoded.method || "unknown",
|
|
25
|
+
success: false,
|
|
26
|
+
error: decoded.error,
|
|
27
|
+
});
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const request = decoded.value;
|
|
31
|
+
const cacheKey = `${deviceId}:${request.id}`;
|
|
32
|
+
const prior = this.completed.get(cacheKey);
|
|
33
|
+
if (prior) {
|
|
34
|
+
if (connection.readyState === FRAME_CONNECTION_OPEN)
|
|
35
|
+
connection.send(prior);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
let operation = this.active.get(cacheKey);
|
|
39
|
+
if (!operation) {
|
|
40
|
+
if (this.active.size >= MAX_ACTIVE_REQUESTS) {
|
|
41
|
+
this.send(connection, failure(request, {
|
|
42
|
+
code: "conflict",
|
|
43
|
+
message: "Too many backend requests are active",
|
|
44
|
+
}));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
operation = this.execute(request, cacheKey);
|
|
48
|
+
this.active.set(cacheKey, operation);
|
|
49
|
+
}
|
|
50
|
+
void operation.then((encoded) => {
|
|
51
|
+
if (connection.readyState === FRAME_CONNECTION_OPEN)
|
|
52
|
+
connection.send(encoded);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async execute(request, cacheKey) {
|
|
57
|
+
let response;
|
|
58
|
+
try {
|
|
59
|
+
response = {
|
|
60
|
+
v: BACKEND_PROTOCOL_VERSION,
|
|
61
|
+
kind: "response",
|
|
62
|
+
id: request.id,
|
|
63
|
+
method: request.method,
|
|
64
|
+
success: true,
|
|
65
|
+
result: await this.dispatch(request),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
response = failure(request, toApiError(error));
|
|
70
|
+
}
|
|
71
|
+
const encoded = encodeBackendFrame(response);
|
|
72
|
+
remember(this.completed, cacheKey, encoded);
|
|
73
|
+
this.active.delete(cacheKey);
|
|
74
|
+
return encoded;
|
|
75
|
+
}
|
|
76
|
+
async dispatch(request) {
|
|
77
|
+
switch (request.method) {
|
|
78
|
+
case "backend.capabilities":
|
|
79
|
+
if (!this.api.getCapabilities)
|
|
80
|
+
throw new Error("Backend capabilities are unavailable");
|
|
81
|
+
return this.api.getCapabilities();
|
|
82
|
+
case "sessions.list": return this.api.listSessions();
|
|
83
|
+
case "sessions.delete": return this.api.deleteSession(request.params.sessionPath).then(() => ({}));
|
|
84
|
+
case "sessions.forkMessages": return this.api.listForkMessages(request.params.sessionPath);
|
|
85
|
+
case "sessions.raw": return this.api.getRawSession(request.params.sessionPath);
|
|
86
|
+
case "files.read": return this.api.getFileContent(request.params.sessionPath, request.params.path);
|
|
87
|
+
case "files.upload.create": return this.api.createFileUpload(request.params);
|
|
88
|
+
case "files.upload.append": return this.api.appendFileUpload(request.params);
|
|
89
|
+
case "files.upload.complete": return this.api.completeFileUpload(request.params.uploadId);
|
|
90
|
+
case "host.browse": return this.api.browseDirectory(request.params.path);
|
|
91
|
+
case "host.mkdir": return this.api.createDirectory(request.params.parentPath, request.params.name);
|
|
92
|
+
case "settings.get": return this.api.getLocalSettings();
|
|
93
|
+
case "settings.validate": return this.api.validateLocalSettings(request.params.content);
|
|
94
|
+
case "settings.patch": return this.api.patchLocalSettings(request.params.patch);
|
|
95
|
+
case "settings.save": return this.api.saveLocalSettings(request.params.content);
|
|
96
|
+
case "updates.get": return this.api.getUpdates();
|
|
97
|
+
case "updates.run": return this.api.runUpdate(request.params.target);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
send(connection, response) {
|
|
101
|
+
if (connection.readyState === FRAME_CONNECTION_OPEN)
|
|
102
|
+
connection.send(encodeBackendFrame(response));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function failure(request, error) {
|
|
106
|
+
return {
|
|
107
|
+
v: BACKEND_PROTOCOL_VERSION,
|
|
108
|
+
kind: "response",
|
|
109
|
+
id: request.id,
|
|
110
|
+
method: request.method,
|
|
111
|
+
success: false,
|
|
112
|
+
error,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function toApiError(error) {
|
|
116
|
+
if (error instanceof LocalBackendApiError)
|
|
117
|
+
return { code: error.code, message: error.message };
|
|
118
|
+
return {
|
|
119
|
+
code: "internal_error",
|
|
120
|
+
message: error instanceof Error ? error.message : String(error),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function remember(cache, id, response) {
|
|
124
|
+
if (Buffer.byteLength(response) > MAX_CACHED_RESPONSE_BYTES)
|
|
125
|
+
return;
|
|
126
|
+
cache.set(id, response);
|
|
127
|
+
if (cache.size <= MAX_COMPLETED_REQUESTS)
|
|
128
|
+
return;
|
|
129
|
+
const oldest = cache.keys().next().value;
|
|
130
|
+
if (oldest)
|
|
131
|
+
cache.delete(oldest);
|
|
132
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } 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
|
+
const BACKEND_TRUST_VERSION = 1;
|
|
6
|
+
const DEFAULT_PAIRING_TTL_MS = 5 * 60_000;
|
|
7
|
+
const MAX_PAIRING_TTL_MS = 15 * 60_000;
|
|
8
|
+
export function defaultBackendTrustPath() {
|
|
9
|
+
const configDir = process.env.PIPANE_CONFIG_DIR || path.join(homedir(), ".config", "pipane");
|
|
10
|
+
return path.join(configDir, "backend-trust.json");
|
|
11
|
+
}
|
|
12
|
+
export class BackendTrustStore {
|
|
13
|
+
filePath;
|
|
14
|
+
now;
|
|
15
|
+
state;
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
this.filePath = options.filePath ?? defaultBackendTrustPath();
|
|
18
|
+
this.now = options.now ?? Date.now;
|
|
19
|
+
mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
|
|
20
|
+
this.state = loadState(this.filePath);
|
|
21
|
+
this.persist();
|
|
22
|
+
}
|
|
23
|
+
get ownerAccountId() {
|
|
24
|
+
return this.state.ownerAccountId;
|
|
25
|
+
}
|
|
26
|
+
createPairing(ttlMs = DEFAULT_PAIRING_TTL_MS) {
|
|
27
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > MAX_PAIRING_TTL_MS) {
|
|
28
|
+
throw new Error("Pairing lifetime is outside the allowed range");
|
|
29
|
+
}
|
|
30
|
+
this.prune();
|
|
31
|
+
const secret = randomBytes(32).toString("base64url");
|
|
32
|
+
const pairing = {
|
|
33
|
+
pairId: `pair_${randomUUID()}`,
|
|
34
|
+
secretHash: hashSecret(secret),
|
|
35
|
+
expiresAt: this.now() + ttlMs,
|
|
36
|
+
};
|
|
37
|
+
this.state.pairings[pairing.pairId] = pairing;
|
|
38
|
+
this.persist();
|
|
39
|
+
return { pairId: pairing.pairId, secret, expiresAt: pairing.expiresAt };
|
|
40
|
+
}
|
|
41
|
+
listActivePairings() {
|
|
42
|
+
this.prune();
|
|
43
|
+
return Object.values(this.state.pairings)
|
|
44
|
+
.filter((pairing) => pairing.usedAt === undefined && pairing.expiresAt > this.now())
|
|
45
|
+
.map(({ pairId, expiresAt }) => ({ pairId, expiresAt }));
|
|
46
|
+
}
|
|
47
|
+
consumePairing(pairId, secret) {
|
|
48
|
+
const pairing = this.state.pairings[pairId];
|
|
49
|
+
if (!pairing || pairing.expiresAt <= this.now() || pairing.usedAt !== undefined) {
|
|
50
|
+
throw new Error("Pairing capability is missing, expired, or already used");
|
|
51
|
+
}
|
|
52
|
+
const actual = Buffer.from(hashSecret(secret));
|
|
53
|
+
const expected = Buffer.from(pairing.secretHash);
|
|
54
|
+
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
|
55
|
+
throw new Error("Invalid pairing secret");
|
|
56
|
+
pairing.usedAt = this.now();
|
|
57
|
+
this.persist();
|
|
58
|
+
}
|
|
59
|
+
authorizeTicket(claims) {
|
|
60
|
+
this.prune();
|
|
61
|
+
if (this.state.usedTickets[claims.ticketId] !== undefined)
|
|
62
|
+
throw new Error("Connection ticket was already used by this backend");
|
|
63
|
+
if (claims.kind === "connection") {
|
|
64
|
+
if (!claims.accountId || claims.accountId !== this.state.ownerAccountId) {
|
|
65
|
+
throw new Error("Connection ticket account does not own this backend");
|
|
66
|
+
}
|
|
67
|
+
if (this.state.revokedDevices[claims.deviceId] !== undefined)
|
|
68
|
+
throw new Error("Device authorization is revoked");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
markTicketUsed(claims) {
|
|
72
|
+
if (this.state.usedTickets[claims.ticketId] !== undefined)
|
|
73
|
+
throw new Error("Connection ticket was already used by this backend");
|
|
74
|
+
this.state.usedTickets[claims.ticketId] = claims.expiresAt;
|
|
75
|
+
this.persist();
|
|
76
|
+
}
|
|
77
|
+
completePairing(accountId) {
|
|
78
|
+
if (this.state.ownerAccountId && this.state.ownerAccountId !== accountId) {
|
|
79
|
+
throw new Error("Backend is already owned by another account");
|
|
80
|
+
}
|
|
81
|
+
this.state.ownerAccountId = accountId;
|
|
82
|
+
this.persist();
|
|
83
|
+
}
|
|
84
|
+
applyRevocation(accountId, deviceId) {
|
|
85
|
+
if (this.state.ownerAccountId !== accountId)
|
|
86
|
+
return;
|
|
87
|
+
if (deviceId)
|
|
88
|
+
this.state.revokedDevices[deviceId] = this.now();
|
|
89
|
+
else
|
|
90
|
+
this.state.ownerAccountId = undefined;
|
|
91
|
+
this.persist();
|
|
92
|
+
}
|
|
93
|
+
prune() {
|
|
94
|
+
const now = this.now();
|
|
95
|
+
let changed = false;
|
|
96
|
+
for (const [pairId, pairing] of Object.entries(this.state.pairings)) {
|
|
97
|
+
if (pairing.expiresAt > now)
|
|
98
|
+
continue;
|
|
99
|
+
delete this.state.pairings[pairId];
|
|
100
|
+
changed = true;
|
|
101
|
+
}
|
|
102
|
+
for (const [ticketId, expiresAt] of Object.entries(this.state.usedTickets)) {
|
|
103
|
+
if (expiresAt > now)
|
|
104
|
+
continue;
|
|
105
|
+
delete this.state.usedTickets[ticketId];
|
|
106
|
+
changed = true;
|
|
107
|
+
}
|
|
108
|
+
if (changed)
|
|
109
|
+
this.persist();
|
|
110
|
+
}
|
|
111
|
+
persist() {
|
|
112
|
+
const temporaryPath = `${this.filePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
113
|
+
writeFileSync(temporaryPath, `${JSON.stringify(this.state, null, 2)}\n`, { mode: 0o600 });
|
|
114
|
+
renameSync(temporaryPath, this.filePath);
|
|
115
|
+
chmodSync(this.filePath, 0o600);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function loadState(filePath) {
|
|
119
|
+
try {
|
|
120
|
+
const value = JSON.parse(readFileSync(filePath, "utf8"));
|
|
121
|
+
if (!isStoredBackendTrust(value))
|
|
122
|
+
throw new Error("unsupported or malformed state");
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (error?.code !== "ENOENT")
|
|
127
|
+
throw new Error(`Invalid backend trust store: ${error instanceof Error ? error.message : String(error)}`);
|
|
128
|
+
return { version: BACKEND_TRUST_VERSION, pairings: {}, usedTickets: {}, revokedDevices: {} };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function isStoredBackendTrust(value) {
|
|
132
|
+
if (!isRecord(value) || value.version !== BACKEND_TRUST_VERSION)
|
|
133
|
+
return false;
|
|
134
|
+
return (value.ownerAccountId === undefined || isNonEmptyString(value.ownerAccountId))
|
|
135
|
+
&& isRecordOf(value.pairings, (pairing, key) => isRecord(pairing)
|
|
136
|
+
&& pairing.pairId === key
|
|
137
|
+
&& isNonEmptyString(pairing.secretHash)
|
|
138
|
+
&& isNonNegativeInteger(pairing.expiresAt)
|
|
139
|
+
&& (pairing.usedAt === undefined || isNonNegativeInteger(pairing.usedAt)))
|
|
140
|
+
&& isRecordOf(value.usedTickets, isNonNegativeInteger)
|
|
141
|
+
&& isRecordOf(value.revokedDevices, isNonNegativeInteger);
|
|
142
|
+
}
|
|
143
|
+
function isRecord(value) {
|
|
144
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
145
|
+
}
|
|
146
|
+
function isRecordOf(value, predicate) {
|
|
147
|
+
return isRecord(value) && Object.entries(value).every(([key, entry]) => predicate(entry, key));
|
|
148
|
+
}
|
|
149
|
+
function isNonEmptyString(value) {
|
|
150
|
+
return typeof value === "string" && value.length > 0;
|
|
151
|
+
}
|
|
152
|
+
function isNonNegativeInteger(value) {
|
|
153
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
154
|
+
}
|
|
155
|
+
function hashSecret(secret) {
|
|
156
|
+
return createHash("sha256").update(`pipane-pairing-secret-v1\n${secret}`).digest("base64url");
|
|
157
|
+
}
|