surf-cli 2.8.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -8
- package/native/abort.cjs +65 -0
- package/native/activity-journal.cjs +55 -0
- package/native/ai-queue.cjs +64 -0
- package/native/aistudio-build.cjs +21 -13
- package/native/aistudio-client.cjs +40 -20
- package/native/browser-lock.cjs +2 -2
- package/native/chatgpt-client.cjs +49 -31
- package/native/cli.cjs +352 -482
- package/native/client-transport.cjs +168 -0
- package/native/do-executor.cjs +68 -510
- package/native/do-parser.cjs +8 -249
- package/native/doctor.cjs +55 -5
- package/native/endpoint.cjs +174 -0
- package/native/file-transfer.cjs +734 -0
- package/native/gemini-client.cjs +156 -71
- package/native/grok-client.cjs +98 -89
- package/native/host-helpers.cjs +43 -26
- package/native/host-sessions.cjs +287 -0
- package/native/host.cjs +998 -620
- package/native/listener.cjs +20 -0
- package/native/mcp-server.cjs +60 -65
- package/native/network-export.cjs +116 -0
- package/native/network-store.cjs +38 -58
- package/native/perplexity-client.cjs +46 -17
- package/native/playbook-authoring.cjs +44 -0
- package/native/playbook-cli.cjs +157 -0
- package/native/playbook-client.cjs +259 -0
- package/native/playbook-receipts.cjs +109 -0
- package/native/playbook-records.cjs +208 -0
- package/native/playbook-runtime.cjs +177 -0
- package/native/playbooks.cjs +235 -0
- package/native/private-state.cjs +156 -0
- package/native/redaction.cjs +104 -0
- package/native/remote-auth.cjs +279 -0
- package/native/remote-transport.cjs +337 -0
- package/native/request-pending.cjs +148 -0
- package/native/socket-path.cjs +1 -1
- package/native/workflow-definition.cjs +368 -0
- package/native/workflow-runtime.cjs +225 -0
- package/package.json +9 -6
- package/playbooks/page/ops/read.json +22 -0
- package/playbooks/page/playbook.json +7 -0
- package/scripts/install-native-host.cjs +36 -5
- package/skills/README.md +11 -5
- package/skills/deep-x-research/SKILL.md +106 -0
- package/skills/surf/SKILL.md +72 -5
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
const crypto = require("crypto");
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
const PROTOCOL_VERSION = 1;
|
|
7
|
+
const NONCE_BYTES = 32;
|
|
8
|
+
const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
9
|
+
const CLIENT_ID_PATTERN = /^[a-f0-9]{32}$/;
|
|
10
|
+
const STATE_DIR_MODE = 0o700;
|
|
11
|
+
const STATE_FILE_MODE = 0o600;
|
|
12
|
+
const DEFAULT_STATE_DIR = path.join(os.homedir(), ".surf", "remote");
|
|
13
|
+
const HOST_IDENTITY_FILE = "host-identity.json";
|
|
14
|
+
const CLIENT_REGISTRY_FILE = "remote-clients.json";
|
|
15
|
+
const AUTH_DOMAIN = "surf-cli-remote-auth-v1";
|
|
16
|
+
|
|
17
|
+
function getStateDir(env = process.env) {
|
|
18
|
+
return env.SURF_REMOTE_STATE_DIR || DEFAULT_STATE_DIR;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function assertSafeLabel(label) {
|
|
22
|
+
if (typeof label !== "string" || !LABEL_PATTERN.test(label)) {
|
|
23
|
+
throw new Error("remote client label must be 1-64 characters using letters, numbers, dot, underscore, or hyphen");
|
|
24
|
+
}
|
|
25
|
+
return label;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function assertClientId(clientId) {
|
|
29
|
+
if (typeof clientId !== "string" || !CLIENT_ID_PATTERN.test(clientId)) throw new Error("remote client ID is invalid");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function statePaths(stateDir = getStateDir()) {
|
|
33
|
+
return {
|
|
34
|
+
stateDir,
|
|
35
|
+
hostIdentity: path.join(stateDir, HOST_IDENTITY_FILE),
|
|
36
|
+
registry: path.join(stateDir, CLIENT_REGISTRY_FILE),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assertNotSymlink(filePath, allowMissing = true) {
|
|
41
|
+
try {
|
|
42
|
+
const stat = fs.lstatSync(filePath);
|
|
43
|
+
if (stat.isSymbolicLink()) throw new Error(`refusing symbolic link: ${filePath}`);
|
|
44
|
+
return stat;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (allowMissing && error?.code === "ENOENT") return null;
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ensureStateDir(stateDir) {
|
|
52
|
+
assertNotSymlink(stateDir, true);
|
|
53
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: STATE_DIR_MODE });
|
|
54
|
+
const stat = assertNotSymlink(stateDir, false);
|
|
55
|
+
if (!stat.isDirectory()) throw new Error(`remote state path is not a directory: ${stateDir}`);
|
|
56
|
+
fs.chmodSync(stateDir, STATE_DIR_MODE);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function assertPrivateFile(filePath) {
|
|
60
|
+
const stat = assertNotSymlink(filePath, false);
|
|
61
|
+
if (!stat.isFile()) throw new Error(`remote state path is not a file: ${filePath}`);
|
|
62
|
+
if ((stat.mode & 0o077) !== 0) throw new Error(`remote state file permissions are too broad: ${filePath}`);
|
|
63
|
+
return stat;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function atomicWriteJson(filePath, value) {
|
|
67
|
+
const dir = path.dirname(filePath);
|
|
68
|
+
assertNotSymlink(dir, true);
|
|
69
|
+
fs.mkdirSync(dir, { recursive: true, mode: STATE_DIR_MODE });
|
|
70
|
+
assertNotSymlink(dir, false);
|
|
71
|
+
assertNotSymlink(filePath, true);
|
|
72
|
+
const tempPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(8).toString("hex")}.tmp`);
|
|
73
|
+
let created = false;
|
|
74
|
+
try {
|
|
75
|
+
const fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, STATE_FILE_MODE);
|
|
76
|
+
created = true;
|
|
77
|
+
try {
|
|
78
|
+
fs.writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
79
|
+
fs.fsyncSync(fd);
|
|
80
|
+
} finally {
|
|
81
|
+
fs.closeSync(fd);
|
|
82
|
+
}
|
|
83
|
+
fs.renameSync(tempPath, filePath);
|
|
84
|
+
fs.chmodSync(filePath, STATE_FILE_MODE);
|
|
85
|
+
} finally {
|
|
86
|
+
if (created) {
|
|
87
|
+
try { fs.unlinkSync(tempPath); } catch {}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readJson(filePath, fallback) {
|
|
93
|
+
const stat = assertNotSymlink(filePath, true);
|
|
94
|
+
if (!stat) return fallback;
|
|
95
|
+
assertPrivateFile(filePath);
|
|
96
|
+
const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function exportKey(keyObject, type) {
|
|
101
|
+
return keyObject.export({ type, format: "der" }).toString("base64");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function importPublicKey(value) {
|
|
105
|
+
if (typeof value !== "string" || !value) throw new Error("remote public key is missing");
|
|
106
|
+
return crypto.createPublicKey({ key: Buffer.from(value, "base64"), type: "spki", format: "der" });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function importPrivateKey(value) {
|
|
110
|
+
if (typeof value !== "string" || !value) throw new Error("remote private key is missing");
|
|
111
|
+
return crypto.createPrivateKey({ key: Buffer.from(value, "base64"), type: "pkcs8", format: "der" });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function loadHostIdentity(stateDir = getStateDir()) {
|
|
115
|
+
const { hostIdentity } = statePaths(stateDir);
|
|
116
|
+
const value = readJson(hostIdentity, null);
|
|
117
|
+
if (!value || value.version !== 1 || typeof value.publicKey !== "string" || typeof value.privateKey !== "string") {
|
|
118
|
+
throw new Error("remote host identity is invalid");
|
|
119
|
+
}
|
|
120
|
+
const privateKey = importPrivateKey(value.privateKey);
|
|
121
|
+
const publicKey = importPublicKey(value.publicKey);
|
|
122
|
+
return { stateDir, path: hostIdentity, publicKey: value.publicKey, privateKey, publicKeyObject: publicKey };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function ensureHostIdentity(stateDir = getStateDir()) {
|
|
126
|
+
ensureStateDir(stateDir);
|
|
127
|
+
const { hostIdentity } = statePaths(stateDir);
|
|
128
|
+
const existing = readJson(hostIdentity, null);
|
|
129
|
+
if (existing) return loadHostIdentity(stateDir);
|
|
130
|
+
const pair = crypto.generateKeyPairSync("ed25519");
|
|
131
|
+
const value = { version: 1, publicKey: exportKey(pair.publicKey, "spki"), privateKey: exportKey(pair.privateKey, "pkcs8"), createdAt: new Date().toISOString() };
|
|
132
|
+
atomicWriteJson(hostIdentity, value);
|
|
133
|
+
return loadHostIdentity(stateDir);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function loadRegistry(stateDir = getStateDir()) {
|
|
137
|
+
ensureStateDir(stateDir);
|
|
138
|
+
const { registry } = statePaths(stateDir);
|
|
139
|
+
const value = readJson(registry, null);
|
|
140
|
+
if (!value) return { version: 1, clients: [] };
|
|
141
|
+
if (value.version !== 1 || !Array.isArray(value.clients)) throw new Error("remote client registry is invalid");
|
|
142
|
+
for (const client of value.clients) {
|
|
143
|
+
assertClientId(client.id);
|
|
144
|
+
assertSafeLabel(client.label);
|
|
145
|
+
importPublicKey(client.publicKey);
|
|
146
|
+
}
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function saveRegistry(stateDir, registry) {
|
|
151
|
+
const { registry: registryPath } = statePaths(stateDir);
|
|
152
|
+
atomicWriteJson(registryPath, registry);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function authorizeClient(label, outputPath, stateDir = getStateDir()) {
|
|
156
|
+
assertSafeLabel(label);
|
|
157
|
+
if (typeof outputPath !== "string" || !outputPath) throw new Error("--output is required for remote authorize");
|
|
158
|
+
ensureStateDir(stateDir);
|
|
159
|
+
const host = ensureHostIdentity(stateDir);
|
|
160
|
+
const registry = loadRegistry(stateDir);
|
|
161
|
+
if (registry.clients.some((client) => client.label === label)) throw new Error(`remote client label already exists: ${label}`);
|
|
162
|
+
const output = path.resolve(outputPath);
|
|
163
|
+
assertNotSymlink(output, true);
|
|
164
|
+
if (fs.existsSync(output)) throw new Error(`credential output already exists: ${output}`);
|
|
165
|
+
const pair = crypto.generateKeyPairSync("ed25519");
|
|
166
|
+
const client = {
|
|
167
|
+
id: crypto.randomBytes(16).toString("hex"),
|
|
168
|
+
label,
|
|
169
|
+
publicKey: exportKey(pair.publicKey, "spki"),
|
|
170
|
+
createdAt: new Date().toISOString(),
|
|
171
|
+
};
|
|
172
|
+
const credential = {
|
|
173
|
+
version: 1,
|
|
174
|
+
clientId: client.id,
|
|
175
|
+
label,
|
|
176
|
+
privateKey: exportKey(pair.privateKey, "pkcs8"),
|
|
177
|
+
publicKey: client.publicKey,
|
|
178
|
+
hostPublicKey: host.publicKey,
|
|
179
|
+
};
|
|
180
|
+
registry.clients.push(client);
|
|
181
|
+
saveRegistry(stateDir, registry);
|
|
182
|
+
try {
|
|
183
|
+
atomicWriteJson(output, credential);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
registry.clients = registry.clients.filter((entry) => entry.id !== client.id);
|
|
186
|
+
saveRegistry(stateDir, registry);
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
return { ...client, output, hostPublicKey: host.publicKey };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function listClients(stateDir = getStateDir()) {
|
|
193
|
+
return loadRegistry(stateDir).clients.map(({ id, label, createdAt }) => ({ id, label, createdAt }));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function revokeClient(label, stateDir = getStateDir()) {
|
|
197
|
+
assertSafeLabel(label);
|
|
198
|
+
const registry = loadRegistry(stateDir);
|
|
199
|
+
const before = registry.clients.length;
|
|
200
|
+
registry.clients = registry.clients.filter((client) => client.label !== label);
|
|
201
|
+
if (registry.clients.length === before) throw new Error(`remote client not found: ${label}`);
|
|
202
|
+
saveRegistry(stateDir, registry);
|
|
203
|
+
return { label };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function loadCredential(credentialPath) {
|
|
207
|
+
if (typeof credentialPath !== "string" || !credentialPath) throw new Error("remote credential path is required");
|
|
208
|
+
const resolved = path.resolve(credentialPath);
|
|
209
|
+
assertPrivateFile(resolved);
|
|
210
|
+
const value = JSON.parse(fs.readFileSync(resolved, "utf8"));
|
|
211
|
+
if (value.version !== 1 || typeof value.clientId !== "string" || typeof value.label !== "string" || typeof value.publicKey !== "string" || typeof value.hostPublicKey !== "string") {
|
|
212
|
+
throw new Error("remote credential is invalid");
|
|
213
|
+
}
|
|
214
|
+
assertClientId(value.clientId);
|
|
215
|
+
assertSafeLabel(value.label);
|
|
216
|
+
const privateKey = importPrivateKey(value.privateKey);
|
|
217
|
+
const publicKey = importPublicKey(value.publicKey);
|
|
218
|
+
importPublicKey(value.hostPublicKey);
|
|
219
|
+
return { ...value, path: resolved, privateKey, publicKeyObject: publicKey };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function canonicalTranscript(role, clientId, clientNonce, serverNonce, clientPublicKey, hostPublicKey) {
|
|
223
|
+
assertClientId(clientId);
|
|
224
|
+
for (const [name, value] of [["clientNonce", clientNonce], ["serverNonce", serverNonce], ["clientPublicKey", clientPublicKey], ["hostPublicKey", hostPublicKey]]) {
|
|
225
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) throw new Error(`${name} is invalid`);
|
|
226
|
+
}
|
|
227
|
+
if (role !== "server" && role !== "client") throw new Error("authentication role is invalid");
|
|
228
|
+
return Buffer.from([AUTH_DOMAIN, String(PROTOCOL_VERSION), role, clientId, clientNonce, serverNonce, clientPublicKey, hostPublicKey].join("\0"), "utf8");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function verifySignature(publicKey, data, signature) {
|
|
232
|
+
return crypto.verify(null, data, publicKey, Buffer.from(signature, "base64"));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function signChallenge(host, clientId, clientNonce, serverNonce, clientPublicKey) {
|
|
236
|
+
const data = canonicalTranscript("server", clientId, clientNonce, serverNonce, clientPublicKey, host.publicKey);
|
|
237
|
+
return crypto.sign(null, data, host.privateKey).toString("base64");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function signProof(credential, clientNonce, serverNonce) {
|
|
241
|
+
const data = canonicalTranscript("client", credential.clientId, clientNonce, serverNonce, credential.publicKey, credential.hostPublicKey);
|
|
242
|
+
return crypto.sign(null, data, credential.privateKey).toString("base64");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function verifyChallenge(credential, challenge) {
|
|
246
|
+
if (challenge.version !== PROTOCOL_VERSION || challenge.clientId !== credential.clientId || challenge.hostPublicKey !== credential.hostPublicKey) return false;
|
|
247
|
+
const data = canonicalTranscript("server", credential.clientId, challenge.clientNonce, challenge.serverNonce, credential.publicKey, credential.hostPublicKey);
|
|
248
|
+
return verifySignature(importPublicKey(credential.hostPublicKey), data, challenge.signature);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function verifyProof(client, hostPublicKey, clientNonce, serverNonce, proof) {
|
|
252
|
+
const data = canonicalTranscript("client", client.id, clientNonce, serverNonce, client.publicKey, hostPublicKey);
|
|
253
|
+
return verifySignature(importPublicKey(client.publicKey), data, proof);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = {
|
|
257
|
+
AUTH_DOMAIN,
|
|
258
|
+
CLIENT_ID_PATTERN,
|
|
259
|
+
LABEL_PATTERN,
|
|
260
|
+
NONCE_BYTES,
|
|
261
|
+
PROTOCOL_VERSION,
|
|
262
|
+
STATE_DIR_MODE,
|
|
263
|
+
STATE_FILE_MODE,
|
|
264
|
+
authorizeClient,
|
|
265
|
+
canonicalTranscript,
|
|
266
|
+
ensureHostIdentity,
|
|
267
|
+
getStateDir,
|
|
268
|
+
listClients,
|
|
269
|
+
loadCredential,
|
|
270
|
+
loadHostIdentity,
|
|
271
|
+
loadRegistry,
|
|
272
|
+
revokeClient,
|
|
273
|
+
signChallenge,
|
|
274
|
+
signProof,
|
|
275
|
+
statePaths,
|
|
276
|
+
verifyChallenge,
|
|
277
|
+
verifyProof,
|
|
278
|
+
importPublicKey,
|
|
279
|
+
};
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
const net = require("net");
|
|
2
|
+
const crypto = require("crypto");
|
|
3
|
+
const { loadCredential, loadHostIdentity, loadRegistry, NONCE_BYTES, PROTOCOL_VERSION, signProof, signChallenge, verifyChallenge, verifyProof } = require("./remote-auth.cjs");
|
|
4
|
+
|
|
5
|
+
const MAX_FRAME_BYTES = 1024 * 1024;
|
|
6
|
+
const DEFAULT_FRAME_TIMEOUT_MS = 10000;
|
|
7
|
+
const DEFAULT_AUTH_TIMEOUT_MS = 5000;
|
|
8
|
+
|
|
9
|
+
function writeFrame(socket, value) {
|
|
10
|
+
const encoded = Buffer.from(`${JSON.stringify(value)}\n`, "utf8");
|
|
11
|
+
if (encoded.length - 1 > MAX_FRAME_BYTES) return Promise.reject(new Error("frame exceeds byte limit"));
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
let settled = false;
|
|
14
|
+
let drainListener;
|
|
15
|
+
const cleanup = () => {
|
|
16
|
+
socket.removeListener("error", fail);
|
|
17
|
+
socket.removeListener("close", close);
|
|
18
|
+
if (drainListener) socket.removeListener("drain", drainListener);
|
|
19
|
+
};
|
|
20
|
+
const fail = (error) => {
|
|
21
|
+
if (settled) return;
|
|
22
|
+
settled = true;
|
|
23
|
+
cleanup();
|
|
24
|
+
reject(error);
|
|
25
|
+
};
|
|
26
|
+
const done = () => {
|
|
27
|
+
if (settled) return;
|
|
28
|
+
settled = true;
|
|
29
|
+
cleanup();
|
|
30
|
+
resolve();
|
|
31
|
+
};
|
|
32
|
+
const close = () => fail(new Error("socket closed while writing frame"));
|
|
33
|
+
socket.once("error", fail);
|
|
34
|
+
socket.once("close", close);
|
|
35
|
+
if (socket.write(encoded)) {
|
|
36
|
+
done();
|
|
37
|
+
} else {
|
|
38
|
+
drainListener = done;
|
|
39
|
+
socket.once("drain", drainListener);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createSocketWriter(socket, { maxPendingBytes = 4 * 1024 * 1024, onOverflow = () => {} } = {}) {
|
|
45
|
+
const queue = [];
|
|
46
|
+
let pendingBytes = 0;
|
|
47
|
+
let writing = false;
|
|
48
|
+
let current;
|
|
49
|
+
let currentErrorListener;
|
|
50
|
+
let closed = false;
|
|
51
|
+
let drainListener;
|
|
52
|
+
const failQueue = (error) => {
|
|
53
|
+
while (queue.length) queue.shift().reject(error);
|
|
54
|
+
pendingBytes = 0;
|
|
55
|
+
};
|
|
56
|
+
const close = (error = new Error("socket writer closed")) => {
|
|
57
|
+
if (closed) return;
|
|
58
|
+
closed = true;
|
|
59
|
+
if (drainListener) socket.removeListener("drain", drainListener);
|
|
60
|
+
if (current) {
|
|
61
|
+
const item = current;
|
|
62
|
+
current = undefined;
|
|
63
|
+
writing = false;
|
|
64
|
+
if (currentErrorListener) socket.removeListener("error", currentErrorListener);
|
|
65
|
+
currentErrorListener = undefined;
|
|
66
|
+
item.reject(error);
|
|
67
|
+
}
|
|
68
|
+
failQueue(error);
|
|
69
|
+
};
|
|
70
|
+
const pump = () => {
|
|
71
|
+
if (writing || closed || queue.length === 0) return;
|
|
72
|
+
writing = true;
|
|
73
|
+
const item = queue.shift();
|
|
74
|
+
current = item;
|
|
75
|
+
const finish = (error) => {
|
|
76
|
+
if (!writing) return;
|
|
77
|
+
writing = false;
|
|
78
|
+
current = undefined;
|
|
79
|
+
pendingBytes -= item.bytes;
|
|
80
|
+
socket.removeListener("error", finish);
|
|
81
|
+
if (currentErrorListener === finish) currentErrorListener = undefined;
|
|
82
|
+
if (drainListener) {
|
|
83
|
+
socket.removeListener("drain", drainListener);
|
|
84
|
+
drainListener = undefined;
|
|
85
|
+
}
|
|
86
|
+
if (error) item.reject(error);
|
|
87
|
+
else item.resolve();
|
|
88
|
+
pump();
|
|
89
|
+
};
|
|
90
|
+
currentErrorListener = finish;
|
|
91
|
+
socket.once("error", finish);
|
|
92
|
+
try {
|
|
93
|
+
if (socket.write(item.encoded)) {
|
|
94
|
+
socket.removeListener("error", finish);
|
|
95
|
+
finish();
|
|
96
|
+
} else {
|
|
97
|
+
drainListener = () => {
|
|
98
|
+
drainListener = undefined;
|
|
99
|
+
socket.removeListener("error", finish);
|
|
100
|
+
finish();
|
|
101
|
+
};
|
|
102
|
+
socket.once("drain", drainListener);
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
socket.removeListener("error", finish);
|
|
106
|
+
finish(error);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const send = (value, { stream = false } = {}) => {
|
|
110
|
+
if (closed) return Promise.reject(new Error("socket writer is closed"));
|
|
111
|
+
const encoded = Buffer.from(`${JSON.stringify(value)}\n`, "utf8");
|
|
112
|
+
if (encoded.length - 1 > MAX_FRAME_BYTES) return Promise.reject(new Error("frame exceeds byte limit"));
|
|
113
|
+
if (pendingBytes + encoded.length > maxPendingBytes) {
|
|
114
|
+
const error = new Error("socket writer queue is full");
|
|
115
|
+
onOverflow({ stream, error });
|
|
116
|
+
return Promise.reject(error);
|
|
117
|
+
}
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
queue.push({ encoded, bytes: encoded.length, resolve, reject });
|
|
120
|
+
pendingBytes += encoded.length;
|
|
121
|
+
pump();
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
return { send, close, get pendingBytes() { return pendingBytes; } };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function createFrameParser({ onFrame, onError, maxFrameBytes = MAX_FRAME_BYTES, frameTimeoutMs = DEFAULT_FRAME_TIMEOUT_MS }) {
|
|
128
|
+
let buffer = Buffer.alloc(0);
|
|
129
|
+
let timer = null;
|
|
130
|
+
let closed = false;
|
|
131
|
+
const fail = (error) => {
|
|
132
|
+
if (closed) return;
|
|
133
|
+
closed = true;
|
|
134
|
+
if (timer) clearTimeout(timer);
|
|
135
|
+
onError(error);
|
|
136
|
+
};
|
|
137
|
+
const armDeadline = () => {
|
|
138
|
+
if (timer || buffer.length === 0) return;
|
|
139
|
+
timer = setTimeout(() => fail(new Error("incomplete frame timed out")), frameTimeoutMs);
|
|
140
|
+
};
|
|
141
|
+
const clearDeadline = () => {
|
|
142
|
+
if (buffer.length === 0 && timer) {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
timer = null;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
push(chunk) {
|
|
149
|
+
if (closed) return;
|
|
150
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
151
|
+
if (buffer.length > maxFrameBytes + 1 && buffer.indexOf(0x0a) === -1) {
|
|
152
|
+
fail(new Error("frame exceeds byte limit"));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
let newline;
|
|
156
|
+
while ((newline = buffer.indexOf(0x0a)) !== -1) {
|
|
157
|
+
const frame = buffer.subarray(0, newline);
|
|
158
|
+
buffer = buffer.subarray(newline + 1);
|
|
159
|
+
if (frame.length > maxFrameBytes) {
|
|
160
|
+
fail(new Error("frame exceeds byte limit"));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (frame.length === 0) continue;
|
|
164
|
+
let line;
|
|
165
|
+
try {
|
|
166
|
+
line = new TextDecoder("utf-8", { fatal: true }).decode(frame);
|
|
167
|
+
} catch {
|
|
168
|
+
fail(new Error("frame contains invalid UTF-8"));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
let value;
|
|
172
|
+
try {
|
|
173
|
+
value = JSON.parse(line);
|
|
174
|
+
} catch {
|
|
175
|
+
fail(new Error("frame contains invalid JSON"));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
onFrame(value);
|
|
179
|
+
if (closed) return;
|
|
180
|
+
}
|
|
181
|
+
if (buffer.length > maxFrameBytes) {
|
|
182
|
+
fail(new Error("frame exceeds byte limit"));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
clearDeadline();
|
|
186
|
+
armDeadline();
|
|
187
|
+
},
|
|
188
|
+
close() {
|
|
189
|
+
closed = true;
|
|
190
|
+
if (timer) clearTimeout(timer);
|
|
191
|
+
timer = null;
|
|
192
|
+
buffer = Buffer.alloc(0);
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function waitForFrame(socket, timeoutMs = DEFAULT_AUTH_TIMEOUT_MS) {
|
|
198
|
+
return new Promise((resolve, reject) => {
|
|
199
|
+
let settled = false;
|
|
200
|
+
const parser = createFrameParser({
|
|
201
|
+
onFrame(value) {
|
|
202
|
+
if (settled) return;
|
|
203
|
+
settled = true;
|
|
204
|
+
cleanup();
|
|
205
|
+
resolve(value);
|
|
206
|
+
},
|
|
207
|
+
onError(error) {
|
|
208
|
+
if (settled) return;
|
|
209
|
+
settled = true;
|
|
210
|
+
cleanup();
|
|
211
|
+
reject(error);
|
|
212
|
+
},
|
|
213
|
+
frameTimeoutMs: timeoutMs,
|
|
214
|
+
});
|
|
215
|
+
const timer = setTimeout(() => {
|
|
216
|
+
if (settled) return;
|
|
217
|
+
settled = true;
|
|
218
|
+
cleanup();
|
|
219
|
+
reject(new Error("authentication timed out"));
|
|
220
|
+
}, timeoutMs);
|
|
221
|
+
const onData = (chunk) => parser.push(chunk);
|
|
222
|
+
const onError = (error) => {
|
|
223
|
+
if (settled) return;
|
|
224
|
+
settled = true;
|
|
225
|
+
cleanup();
|
|
226
|
+
reject(error);
|
|
227
|
+
};
|
|
228
|
+
const onClose = () => onError(new Error("connection closed during authentication"));
|
|
229
|
+
const cleanup = () => {
|
|
230
|
+
clearTimeout(timer);
|
|
231
|
+
parser.close();
|
|
232
|
+
socket.removeListener("data", onData);
|
|
233
|
+
socket.removeListener("error", onError);
|
|
234
|
+
socket.removeListener("close", onClose);
|
|
235
|
+
};
|
|
236
|
+
socket.on("data", onData);
|
|
237
|
+
socket.once("error", onError);
|
|
238
|
+
socket.once("close", onClose);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function authenticateClient(socket, credentialPathOrValue, options = {}) {
|
|
243
|
+
const credential = typeof credentialPathOrValue === "string" ? loadCredential(credentialPathOrValue) : credentialPathOrValue;
|
|
244
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS;
|
|
245
|
+
const clientNonce = crypto.randomBytes(NONCE_BYTES).toString("base64");
|
|
246
|
+
await writeFrame(socket, { type: "auth_hello", version: PROTOCOL_VERSION, clientId: credential.clientId, clientNonce });
|
|
247
|
+
const challenge = await waitForFrame(socket, timeoutMs);
|
|
248
|
+
if (challenge.type === "auth_error") throw new Error(challenge.message || "remote authentication rejected");
|
|
249
|
+
if (challenge.type !== "auth_challenge") throw new Error("remote authentication protocol error");
|
|
250
|
+
if (!verifyChallenge(credential, challenge)) throw new Error("remote server identity verification failed");
|
|
251
|
+
const proof = signProof(credential, clientNonce, challenge.serverNonce);
|
|
252
|
+
await writeFrame(socket, { type: "auth_proof", version: PROTOCOL_VERSION, clientId: credential.clientId, proof });
|
|
253
|
+
const result = await waitForFrame(socket, timeoutMs);
|
|
254
|
+
if (result.type !== "auth_ok" || result.clientId !== credential.clientId) throw new Error(result.message || "remote authentication failed");
|
|
255
|
+
return { clientId: credential.clientId, label: credential.label };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function createServerAuthSession({ socket, stateDir, send = (value) => writeFrame(socket, value), timeoutMs = DEFAULT_AUTH_TIMEOUT_MS, onAuthenticated, onError }) {
|
|
259
|
+
let state = "pending";
|
|
260
|
+
let timer = setTimeout(() => fail(new Error("authentication timed out")), timeoutMs);
|
|
261
|
+
let clientNonce;
|
|
262
|
+
let serverNonce;
|
|
263
|
+
let client;
|
|
264
|
+
let host;
|
|
265
|
+
const fail = (error) => {
|
|
266
|
+
if (state === "authenticated" || state === "failed") return;
|
|
267
|
+
state = "failed";
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
onError(error);
|
|
270
|
+
};
|
|
271
|
+
const handle = async (message) => {
|
|
272
|
+
if (state !== "pending") return false;
|
|
273
|
+
try {
|
|
274
|
+
if (message.type !== "auth_hello") throw new Error("authentication required before requests");
|
|
275
|
+
if (message.version !== PROTOCOL_VERSION || typeof message.clientNonce !== "string" || Buffer.from(message.clientNonce, "base64").length !== NONCE_BYTES) throw new Error("invalid authentication hello");
|
|
276
|
+
const registry = loadRegistry(stateDir);
|
|
277
|
+
client = registry.clients.find((entry) => entry.id === message.clientId);
|
|
278
|
+
if (!client) throw new Error("remote client is not authorized");
|
|
279
|
+
host = loadHostIdentity(stateDir);
|
|
280
|
+
clientNonce = message.clientNonce;
|
|
281
|
+
serverNonce = crypto.randomBytes(NONCE_BYTES).toString("base64");
|
|
282
|
+
const signature = signChallenge(host, client.id, clientNonce, serverNonce, client.publicKey);
|
|
283
|
+
await send({ type: "auth_challenge", version: PROTOCOL_VERSION, clientId: client.id, clientNonce, serverNonce, hostPublicKey: host.publicKey, signature });
|
|
284
|
+
state = "proof";
|
|
285
|
+
return true;
|
|
286
|
+
} catch (error) {
|
|
287
|
+
fail(error);
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
const handleProof = async (message) => {
|
|
292
|
+
if (state !== "proof") return false;
|
|
293
|
+
try {
|
|
294
|
+
if (message.type !== "auth_proof" || message.version !== PROTOCOL_VERSION || message.clientId !== client.id || typeof message.proof !== "string") throw new Error("invalid authentication proof");
|
|
295
|
+
if (!verifyProof(client, host.publicKey, clientNonce, serverNonce, message.proof)) throw new Error("remote client proof verification failed");
|
|
296
|
+
await onAuthenticated({ clientId: client.id, label: client.label });
|
|
297
|
+
state = "authenticated";
|
|
298
|
+
clearTimeout(timer);
|
|
299
|
+
await send({ type: "auth_ok", version: PROTOCOL_VERSION, clientId: client.id, label: client.label });
|
|
300
|
+
return true;
|
|
301
|
+
} catch (error) {
|
|
302
|
+
fail(error);
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
return {
|
|
307
|
+
get authenticated() { return state === "authenticated"; },
|
|
308
|
+
get failed() { return state === "failed"; },
|
|
309
|
+
get principal() { return state === "authenticated" ? { clientId: client.id, label: client.label } : null; },
|
|
310
|
+
handle(message) {
|
|
311
|
+
if (state === "pending") return handle(message);
|
|
312
|
+
if (state === "proof") return handleProof(message);
|
|
313
|
+
return Promise.resolve(false);
|
|
314
|
+
},
|
|
315
|
+
close() {
|
|
316
|
+
clearTimeout(timer);
|
|
317
|
+
if (state !== "authenticated") state = "failed";
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function isClientAuthorized(stateDir, clientId) {
|
|
323
|
+
return loadRegistry(stateDir).clients.some((entry) => entry.id === clientId);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
module.exports = {
|
|
327
|
+
DEFAULT_AUTH_TIMEOUT_MS,
|
|
328
|
+
DEFAULT_FRAME_TIMEOUT_MS,
|
|
329
|
+
MAX_FRAME_BYTES,
|
|
330
|
+
createSocketWriter,
|
|
331
|
+
authenticateClient,
|
|
332
|
+
createFrameParser,
|
|
333
|
+
createServerAuthSession,
|
|
334
|
+
isClientAuthorized,
|
|
335
|
+
waitForFrame,
|
|
336
|
+
writeFrame,
|
|
337
|
+
};
|