rest-safe-env 0.1.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/LICENSE +21 -0
- package/README.md +60 -0
- package/bin/rse.js +2 -0
- package/dist/assets/session-BBsX6ffF.js +9 -0
- package/dist/assets/session-CleYXRaY.css +1 -0
- package/dist/cli.js +1984 -0
- package/dist/session.html +13 -0
- package/package.json +63 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1984 @@
|
|
|
1
|
+
import { access, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { createCipheriv, createDecipheriv, createHash, createPublicKey, hkdfSync, randomBytes, timingSafeEqual, verify } from "node:crypto";
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
const DEFAULT_UI_PORT = 47653;
|
|
9
|
+
var MIN_PORT = 1024;
|
|
10
|
+
var MAX_PORT = 65535;
|
|
11
|
+
function getConfigDirPath() {
|
|
12
|
+
const overrideDir = process.env.RSE_CONFIG_DIR;
|
|
13
|
+
if (overrideDir) return path.resolve(overrideDir);
|
|
14
|
+
const home = homedir();
|
|
15
|
+
if (process.platform === "darwin") return path.join(home, "Library", "Application Support", "rest-safe-env");
|
|
16
|
+
if (process.platform === "win32") {
|
|
17
|
+
const appData = process.env.APPDATA;
|
|
18
|
+
if (appData) return path.join(appData, "rest-safe-env");
|
|
19
|
+
return path.join(home, "AppData", "Roaming", "rest-safe-env");
|
|
20
|
+
}
|
|
21
|
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
|
|
22
|
+
if (xdgConfigHome) return path.join(xdgConfigHome, "rest-safe-env");
|
|
23
|
+
return path.join(home, ".config", "rest-safe-env");
|
|
24
|
+
}
|
|
25
|
+
function getConfigFilePath() {
|
|
26
|
+
return path.join(getConfigDirPath(), "config.json");
|
|
27
|
+
}
|
|
28
|
+
async function loadCliConfig() {
|
|
29
|
+
const configPath = getConfigFilePath();
|
|
30
|
+
try {
|
|
31
|
+
const raw = await readFile(configPath, "utf8");
|
|
32
|
+
const persistedPort = JSON.parse(raw).uiPort;
|
|
33
|
+
if (isValidPort(persistedPort)) return { uiPort: persistedPort };
|
|
34
|
+
return { uiPort: DEFAULT_UI_PORT };
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (isMissingFileError$1(error)) return { uiPort: DEFAULT_UI_PORT };
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function setUiPort(uiPort) {
|
|
41
|
+
validatePort(uiPort);
|
|
42
|
+
const dirPath = getConfigDirPath();
|
|
43
|
+
const configPath = getConfigFilePath();
|
|
44
|
+
await mkdir(dirPath, { recursive: true });
|
|
45
|
+
await writeFile(configPath, JSON.stringify({ uiPort }, null, 2) + "\n", "utf8");
|
|
46
|
+
return { uiPort };
|
|
47
|
+
}
|
|
48
|
+
async function clearCliState() {
|
|
49
|
+
const configDir = getConfigDirPath();
|
|
50
|
+
await rm(configDir, {
|
|
51
|
+
recursive: true,
|
|
52
|
+
force: true
|
|
53
|
+
});
|
|
54
|
+
return configDir;
|
|
55
|
+
}
|
|
56
|
+
function parseAndValidatePort(value) {
|
|
57
|
+
const parsed = Number(value);
|
|
58
|
+
if (!Number.isInteger(parsed)) throw new Error(`Port must be an integer between ${MIN_PORT} and ${MAX_PORT}.`);
|
|
59
|
+
validatePort(parsed);
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
function validatePort(uiPort) {
|
|
63
|
+
if (!isValidPort(uiPort)) throw new Error(`Port must be between ${MIN_PORT} and ${MAX_PORT}.`);
|
|
64
|
+
}
|
|
65
|
+
function isValidPort(uiPort) {
|
|
66
|
+
return typeof uiPort === "number" && Number.isInteger(uiPort) && uiPort >= MIN_PORT && uiPort <= MAX_PORT;
|
|
67
|
+
}
|
|
68
|
+
function isMissingFileError$1(error) {
|
|
69
|
+
if (!(error instanceof Error)) return false;
|
|
70
|
+
return "code" in error && error.code === "ENOENT";
|
|
71
|
+
}
|
|
72
|
+
const ENCRYPTED_PREFIX = "enc:v1:";
|
|
73
|
+
var PAIR_LINE_PATTERN = /^(\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*)=(\s*)(.*)$/;
|
|
74
|
+
var COMMENT_LINE_PATTERN = /^\s*#/;
|
|
75
|
+
var BLANK_LINE_PATTERN = /^\s*$/;
|
|
76
|
+
function parseEnvFile(content) {
|
|
77
|
+
const newline = content.includes("\r\n") ? "\r\n" : "\n";
|
|
78
|
+
const endsWithNewline = content.endsWith("\n");
|
|
79
|
+
const lines = content.split(/\r?\n/);
|
|
80
|
+
if (endsWithNewline) lines.pop();
|
|
81
|
+
return {
|
|
82
|
+
entries: lines.map(parseEnvLine),
|
|
83
|
+
newline,
|
|
84
|
+
endsWithNewline
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function serializeEnvDocument(document) {
|
|
88
|
+
const body = document.entries.map(serializeEnvLine).join(document.newline);
|
|
89
|
+
if (document.endsWithNewline) return `${body}${document.newline}`;
|
|
90
|
+
return body;
|
|
91
|
+
}
|
|
92
|
+
function formatParsedEntriesForDebug(entries) {
|
|
93
|
+
const preview = entries.map((entry, index) => {
|
|
94
|
+
if (entry.type === "pair") return {
|
|
95
|
+
index,
|
|
96
|
+
type: entry.type,
|
|
97
|
+
key: entry.key,
|
|
98
|
+
rawValue: entry.rawValue,
|
|
99
|
+
encrypted: entry.encrypted
|
|
100
|
+
};
|
|
101
|
+
if (entry.type === "blank") return {
|
|
102
|
+
index,
|
|
103
|
+
type: entry.type
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
index,
|
|
107
|
+
type: entry.type,
|
|
108
|
+
text: entry.text
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
return JSON.stringify(preview, null, 2);
|
|
112
|
+
}
|
|
113
|
+
function decodeEnvValue(rawValue) {
|
|
114
|
+
if (rawValue.length >= 2 && rawValue.startsWith("\"") && rawValue.endsWith("\"")) return decodeDoubleQuotedValue(rawValue.slice(1, -1));
|
|
115
|
+
if (rawValue.length >= 2 && rawValue.startsWith("'") && rawValue.endsWith("'")) return rawValue.slice(1, -1);
|
|
116
|
+
return rawValue;
|
|
117
|
+
}
|
|
118
|
+
function parseEnvLine(line) {
|
|
119
|
+
if (BLANK_LINE_PATTERN.test(line)) return {
|
|
120
|
+
type: "blank",
|
|
121
|
+
whitespace: line
|
|
122
|
+
};
|
|
123
|
+
if (COMMENT_LINE_PATTERN.test(line)) return {
|
|
124
|
+
type: "comment",
|
|
125
|
+
text: line
|
|
126
|
+
};
|
|
127
|
+
const pairMatch = line.match(PAIR_LINE_PATTERN);
|
|
128
|
+
if (!pairMatch) return {
|
|
129
|
+
type: "comment",
|
|
130
|
+
text: line
|
|
131
|
+
};
|
|
132
|
+
const [, leadingWhitespace, key, spacingBeforeEquals, spacingAfterEquals, rawValue] = pairMatch;
|
|
133
|
+
return {
|
|
134
|
+
type: "pair",
|
|
135
|
+
key,
|
|
136
|
+
rawValue,
|
|
137
|
+
leadingWhitespace,
|
|
138
|
+
spacingBeforeEquals,
|
|
139
|
+
spacingAfterEquals,
|
|
140
|
+
encrypted: rawValue.startsWith(ENCRYPTED_PREFIX)
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function serializeEnvLine(entry) {
|
|
144
|
+
if (entry.type === "pair") return `${entry.leadingWhitespace}${entry.key}${entry.spacingBeforeEquals}=${entry.spacingAfterEquals}${entry.rawValue}`;
|
|
145
|
+
if (entry.type === "blank") return entry.whitespace;
|
|
146
|
+
return entry.text;
|
|
147
|
+
}
|
|
148
|
+
function decodeDoubleQuotedValue(value) {
|
|
149
|
+
let output = "";
|
|
150
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
151
|
+
const current = value[index];
|
|
152
|
+
if (current !== "\\") {
|
|
153
|
+
output += current;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const next = value[index + 1];
|
|
157
|
+
if (next === void 0) {
|
|
158
|
+
output += "\\";
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (next === "n") {
|
|
162
|
+
output += "\n";
|
|
163
|
+
index += 1;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (next === "r") {
|
|
167
|
+
output += "\r";
|
|
168
|
+
index += 1;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (next === "t") {
|
|
172
|
+
output += " ";
|
|
173
|
+
index += 1;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (next === "\"") {
|
|
177
|
+
output += "\"";
|
|
178
|
+
index += 1;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (next === "\\") {
|
|
182
|
+
output += "\\";
|
|
183
|
+
index += 1;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
output += `\\${next}`;
|
|
187
|
+
index += 1;
|
|
188
|
+
}
|
|
189
|
+
return output;
|
|
190
|
+
}
|
|
191
|
+
var ENCRYPTION_PAYLOAD_VERSION = 1;
|
|
192
|
+
var ENCRYPTION_NONCE_LENGTH = 12;
|
|
193
|
+
var ENCRYPTION_TAG_LENGTH = 16;
|
|
194
|
+
function encryptEnvValue(value, keyName, masterKey) {
|
|
195
|
+
assertMasterKey(masterKey);
|
|
196
|
+
const nonce = randomBytes(ENCRYPTION_NONCE_LENGTH);
|
|
197
|
+
const aad = Buffer.from(`rse:v1:${keyName}`, "utf8");
|
|
198
|
+
const cipher = createCipheriv("aes-256-gcm", masterKey, nonce);
|
|
199
|
+
cipher.setAAD(aad);
|
|
200
|
+
const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
201
|
+
const tag = cipher.getAuthTag();
|
|
202
|
+
if (tag.length !== ENCRYPTION_TAG_LENGTH) throw new Error("Invalid encryption tag length.");
|
|
203
|
+
return `${ENCRYPTED_PREFIX}${Buffer.concat([
|
|
204
|
+
Buffer.from([ENCRYPTION_PAYLOAD_VERSION]),
|
|
205
|
+
nonce,
|
|
206
|
+
ciphertext,
|
|
207
|
+
tag
|
|
208
|
+
]).toString("base64")}`;
|
|
209
|
+
}
|
|
210
|
+
function decryptEnvValue(rawValue, keyName, masterKey) {
|
|
211
|
+
assertMasterKey(masterKey);
|
|
212
|
+
if (!rawValue.startsWith("enc:v1:")) return rawValue;
|
|
213
|
+
const encodedPayload = rawValue.slice(7);
|
|
214
|
+
const payload = Buffer.from(encodedPayload, "base64");
|
|
215
|
+
if (payload.length < 1 + ENCRYPTION_NONCE_LENGTH + ENCRYPTION_TAG_LENGTH) throw new Error(`Invalid encrypted payload for key ${keyName}.`);
|
|
216
|
+
if (payload[0] !== ENCRYPTION_PAYLOAD_VERSION) throw new Error(`Unsupported encrypted payload version for key ${keyName}.`);
|
|
217
|
+
const nonceStart = 1;
|
|
218
|
+
const nonceEnd = nonceStart + ENCRYPTION_NONCE_LENGTH;
|
|
219
|
+
const tagStart = payload.length - ENCRYPTION_TAG_LENGTH;
|
|
220
|
+
const nonce = payload.subarray(nonceStart, nonceEnd);
|
|
221
|
+
const ciphertext = payload.subarray(nonceEnd, tagStart);
|
|
222
|
+
const tag = payload.subarray(tagStart);
|
|
223
|
+
const aad = Buffer.from(`rse:v1:${keyName}`, "utf8");
|
|
224
|
+
const decipher = createDecipheriv("aes-256-gcm", masterKey, nonce);
|
|
225
|
+
decipher.setAAD(aad);
|
|
226
|
+
decipher.setAuthTag(tag);
|
|
227
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
228
|
+
}
|
|
229
|
+
function assertMasterKey(masterKey) {
|
|
230
|
+
if (masterKey.length !== 32) throw new Error("Invalid master key length.");
|
|
231
|
+
}
|
|
232
|
+
var CREDENTIAL_FILE_NAME = "credential.json";
|
|
233
|
+
var WRAPPED_MASTER_KEY_FILE_NAME = "wrapped-master-key.json";
|
|
234
|
+
var MASTER_KEY_LENGTH = 32;
|
|
235
|
+
var WRAP_NONCE_LENGTH = 12;
|
|
236
|
+
var WRAP_AAD = Buffer.from("rse:master-key-wrap:v1");
|
|
237
|
+
var KEK_INFO = Buffer.from("rse:kek:v1");
|
|
238
|
+
var PRF_SALT_TEXT = "rest-safe-env:master-key:v1";
|
|
239
|
+
async function hasRegisteredCredentialMaterial() {
|
|
240
|
+
const [credentialExists, wrappedMasterKeyExists] = await Promise.all([fileExists(getCredentialFilePath()), fileExists(getWrappedMasterKeyFilePath())]);
|
|
241
|
+
return credentialExists && wrappedMasterKeyExists;
|
|
242
|
+
}
|
|
243
|
+
async function registerCredentialAndMasterKey(input) {
|
|
244
|
+
await ensureConfigDirectory();
|
|
245
|
+
const prfSalt = getPrfSaltBuffer();
|
|
246
|
+
const prfOutput = decodeBase64$1(input.prfOutput);
|
|
247
|
+
if (prfOutput.length === 0) throw new Error("Invalid prfOutput: empty value.");
|
|
248
|
+
const kek = deriveKek(prfOutput);
|
|
249
|
+
const masterKey = randomBytes(MASTER_KEY_LENGTH);
|
|
250
|
+
const wrapResult = wrapMasterKey(masterKey, kek);
|
|
251
|
+
const credentialRecord = {
|
|
252
|
+
version: 1,
|
|
253
|
+
credentialId: input.credentialId,
|
|
254
|
+
rpId: input.rpId,
|
|
255
|
+
publicKeySpki: input.publicKeySpki,
|
|
256
|
+
signCount: 0,
|
|
257
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
258
|
+
clientDataJSON: input.clientDataJSON,
|
|
259
|
+
attestationObject: input.attestationObject
|
|
260
|
+
};
|
|
261
|
+
const wrappedMasterKeyRecord = {
|
|
262
|
+
version: 1,
|
|
263
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
264
|
+
prfSalt: encodeBase64(prfSalt),
|
|
265
|
+
nonce: encodeBase64(wrapResult.nonce),
|
|
266
|
+
ciphertext: encodeBase64(wrapResult.ciphertext),
|
|
267
|
+
tag: encodeBase64(wrapResult.tag)
|
|
268
|
+
};
|
|
269
|
+
await Promise.all([writeFile(getCredentialFilePath(), JSON.stringify(credentialRecord, null, 2) + "\n", "utf8"), writeFile(getWrappedMasterKeyFilePath(), JSON.stringify(wrappedMasterKeyRecord, null, 2) + "\n", "utf8")]);
|
|
270
|
+
const masterKeyForCaller = Buffer.from(masterKey);
|
|
271
|
+
masterKey.fill(0);
|
|
272
|
+
return {
|
|
273
|
+
prfSalt: encodeBase64(prfSalt),
|
|
274
|
+
masterKey: masterKeyForCaller
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function getPrfSaltBase64() {
|
|
278
|
+
return encodeBase64(getPrfSaltBuffer());
|
|
279
|
+
}
|
|
280
|
+
async function readCredentialSummary() {
|
|
281
|
+
const record = await readCredentialRecord();
|
|
282
|
+
if (!record) return null;
|
|
283
|
+
if (!record.publicKeySpki || typeof record.publicKeySpki !== "string") return null;
|
|
284
|
+
const signCount = Number.isInteger(record.signCount) ? record.signCount : 0;
|
|
285
|
+
return {
|
|
286
|
+
credentialId: record.credentialId,
|
|
287
|
+
rpId: record.rpId,
|
|
288
|
+
publicKeySpki: record.publicKeySpki,
|
|
289
|
+
signCount
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
async function updateCredentialSignCount(nextSignCount) {
|
|
293
|
+
const record = await readCredentialRecord();
|
|
294
|
+
if (!record) throw new Error("Credential record not found.");
|
|
295
|
+
record.signCount = nextSignCount;
|
|
296
|
+
await writeFile(getCredentialFilePath(), JSON.stringify(record, null, 2) + "\n", "utf8");
|
|
297
|
+
}
|
|
298
|
+
async function unwrapMasterKeyFromPrfOutput(prfOutputBase64) {
|
|
299
|
+
const wrappedRecord = await readWrappedMasterKeyRecord();
|
|
300
|
+
const prfOutput = decodeBase64$1(prfOutputBase64);
|
|
301
|
+
if (prfOutput.length === 0) throw new Error("Invalid prfOutput: empty value.");
|
|
302
|
+
const kek = deriveKek(prfOutput);
|
|
303
|
+
const nonce = decodeBase64$1(wrappedRecord.nonce);
|
|
304
|
+
const ciphertext = decodeBase64$1(wrappedRecord.ciphertext);
|
|
305
|
+
const tag = decodeBase64$1(wrappedRecord.tag);
|
|
306
|
+
const decipher = createDecipheriv("aes-256-gcm", kek, nonce);
|
|
307
|
+
decipher.setAAD(WRAP_AAD);
|
|
308
|
+
decipher.setAuthTag(tag);
|
|
309
|
+
const masterKey = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
310
|
+
if (masterKey.length !== MASTER_KEY_LENGTH) throw new Error("Invalid unwrapped master key length.");
|
|
311
|
+
return masterKey;
|
|
312
|
+
}
|
|
313
|
+
async function ensureConfigDirectory() {
|
|
314
|
+
await mkdir(getConfigDirPath(), { recursive: true });
|
|
315
|
+
}
|
|
316
|
+
function getCredentialFilePath() {
|
|
317
|
+
return path.join(getConfigDirPath(), CREDENTIAL_FILE_NAME);
|
|
318
|
+
}
|
|
319
|
+
function getWrappedMasterKeyFilePath() {
|
|
320
|
+
return path.join(getConfigDirPath(), WRAPPED_MASTER_KEY_FILE_NAME);
|
|
321
|
+
}
|
|
322
|
+
function deriveKek(prfOutput) {
|
|
323
|
+
const derived = hkdfSync("sha256", prfOutput, Buffer.alloc(0), KEK_INFO, MASTER_KEY_LENGTH);
|
|
324
|
+
return Buffer.from(derived);
|
|
325
|
+
}
|
|
326
|
+
function wrapMasterKey(masterKey, kek) {
|
|
327
|
+
const nonce = randomBytes(WRAP_NONCE_LENGTH);
|
|
328
|
+
const cipher = createCipheriv("aes-256-gcm", kek, nonce);
|
|
329
|
+
cipher.setAAD(WRAP_AAD);
|
|
330
|
+
return {
|
|
331
|
+
nonce,
|
|
332
|
+
ciphertext: Buffer.concat([cipher.update(masterKey), cipher.final()]),
|
|
333
|
+
tag: cipher.getAuthTag()
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function getPrfSaltBuffer() {
|
|
337
|
+
return createHash("sha256").update(PRF_SALT_TEXT, "utf8").digest();
|
|
338
|
+
}
|
|
339
|
+
function encodeBase64(value) {
|
|
340
|
+
return value.toString("base64");
|
|
341
|
+
}
|
|
342
|
+
function decodeBase64$1(value) {
|
|
343
|
+
return Buffer.from(value, "base64");
|
|
344
|
+
}
|
|
345
|
+
async function fileExists(filePath) {
|
|
346
|
+
try {
|
|
347
|
+
await access(filePath);
|
|
348
|
+
return true;
|
|
349
|
+
} catch {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
async function readCredentialRecord() {
|
|
354
|
+
try {
|
|
355
|
+
const raw = await readFile(getCredentialFilePath(), "utf8");
|
|
356
|
+
return JSON.parse(raw);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (isMissingFileError(error)) return null;
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async function readWrappedMasterKeyRecord() {
|
|
363
|
+
const raw = await readFile(getWrappedMasterKeyFilePath(), "utf8");
|
|
364
|
+
return JSON.parse(raw);
|
|
365
|
+
}
|
|
366
|
+
function isMissingFileError(error) {
|
|
367
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
368
|
+
}
|
|
369
|
+
var SessionApiError = class extends Error {
|
|
370
|
+
constructor(statusCode, errorCode, message) {
|
|
371
|
+
super(message);
|
|
372
|
+
this.statusCode = statusCode;
|
|
373
|
+
this.errorCode = errorCode;
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
var DEFAULT_CONNECT_TIMEOUT_MS = 6e4;
|
|
377
|
+
var LOOPBACK_BIND_HOST = "127.0.0.1";
|
|
378
|
+
var WEBAUTHN_RP_HOST = "localhost";
|
|
379
|
+
var SESSION_HEARTBEAT_TIMEOUT_MS = 3e3;
|
|
380
|
+
var SESSION_HEARTBEAT_CHECK_INTERVAL_MS = 1500;
|
|
381
|
+
var SESSION_STREAM_DISCONNECT_GRACE_MS = 750;
|
|
382
|
+
var SESSION_STREAM_KEEPALIVE_MS = 1e3;
|
|
383
|
+
async function startUiSession(options) {
|
|
384
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
385
|
+
const sessionId = randomBytes(16).toString("hex");
|
|
386
|
+
const token = randomBytes(16).toString("hex");
|
|
387
|
+
const pageHtml = await loadSessionHtml(options.mode);
|
|
388
|
+
const hasCredentialMaterial = await hasRegisteredCredentialMaterial();
|
|
389
|
+
const viewState = options.mode === "view" || options.mode === "import" ? {
|
|
390
|
+
envFilePath: options.envFilePath,
|
|
391
|
+
document: createInitialDocument(options.envFileContent)
|
|
392
|
+
} : null;
|
|
393
|
+
const runState = options.mode === "run" ? {
|
|
394
|
+
commandDisplay: options.commandDisplay,
|
|
395
|
+
envFilePath: options.envFilePath,
|
|
396
|
+
encryptedEntryCount: options.encryptedEntryCount,
|
|
397
|
+
requiresUnlock: options.encryptedEntryCount > 0,
|
|
398
|
+
decided: false
|
|
399
|
+
} : null;
|
|
400
|
+
let markConnected = () => {};
|
|
401
|
+
const connectedPromise = new Promise((resolve) => {
|
|
402
|
+
markConnected = resolve;
|
|
403
|
+
});
|
|
404
|
+
let markSessionDone = () => {};
|
|
405
|
+
const sessionDonePromise = new Promise((resolve) => {
|
|
406
|
+
markSessionDone = resolve;
|
|
407
|
+
});
|
|
408
|
+
let resolveRunDecision = null;
|
|
409
|
+
const runDecisionPromise = options.mode === "run" ? new Promise((resolve) => {
|
|
410
|
+
resolveRunDecision = resolve;
|
|
411
|
+
}) : null;
|
|
412
|
+
let sessionCompleted = false;
|
|
413
|
+
const context = {
|
|
414
|
+
mode: options.mode,
|
|
415
|
+
port: options.port,
|
|
416
|
+
token,
|
|
417
|
+
sessionId,
|
|
418
|
+
pageHtml,
|
|
419
|
+
onConnected: markConnected,
|
|
420
|
+
onSessionComplete: () => {
|
|
421
|
+
if (sessionCompleted) return;
|
|
422
|
+
sessionCompleted = true;
|
|
423
|
+
context.sessionCompleted = true;
|
|
424
|
+
markSessionDone();
|
|
425
|
+
},
|
|
426
|
+
onRunDecision: resolveRunDecision,
|
|
427
|
+
viewState,
|
|
428
|
+
runState,
|
|
429
|
+
pendingRegistration: null,
|
|
430
|
+
pendingUnlock: null,
|
|
431
|
+
hasRegisteredCredentialMaterial: hasCredentialMaterial,
|
|
432
|
+
unlockedMasterKey: null,
|
|
433
|
+
connected: false,
|
|
434
|
+
lastHeartbeatAt: null,
|
|
435
|
+
activeStreamCount: 0,
|
|
436
|
+
disconnectTimer: null,
|
|
437
|
+
sessionCompleted: false
|
|
438
|
+
};
|
|
439
|
+
const server = createServer((request, response) => {
|
|
440
|
+
handleRequest(request, response, context);
|
|
441
|
+
});
|
|
442
|
+
const heartbeatMonitor = setInterval(() => {
|
|
443
|
+
if (!context.connected || context.sessionCompleted || context.lastHeartbeatAt === null || context.activeStreamCount > 0) return;
|
|
444
|
+
if (Date.now() - context.lastHeartbeatAt > SESSION_HEARTBEAT_TIMEOUT_MS) completeSessionFromWindowClose(context);
|
|
445
|
+
}, SESSION_HEARTBEAT_CHECK_INTERVAL_MS);
|
|
446
|
+
try {
|
|
447
|
+
await listenOnFixedPort(server, options.port);
|
|
448
|
+
const url = `http://${WEBAUTHN_RP_HOST}:${options.port}/?session=${sessionId}&token=${token}&mode=${options.mode}`;
|
|
449
|
+
console.log(`[rse] open this URL if browser did not launch automatically: ${url}`);
|
|
450
|
+
await openBrowser(url);
|
|
451
|
+
await waitForConnection(connectedPromise, timeoutMs);
|
|
452
|
+
if (options.mode !== "run") {
|
|
453
|
+
await sessionDonePromise;
|
|
454
|
+
return { mode: options.mode };
|
|
455
|
+
}
|
|
456
|
+
const approved = await (runDecisionPromise ?? Promise.resolve(false));
|
|
457
|
+
await sessionDonePromise;
|
|
458
|
+
const masterKeyForCaller = context.unlockedMasterKey ? Buffer.from(context.unlockedMasterKey) : null;
|
|
459
|
+
wipeMasterKey$1(context.unlockedMasterKey);
|
|
460
|
+
context.unlockedMasterKey = null;
|
|
461
|
+
return {
|
|
462
|
+
mode: "run",
|
|
463
|
+
approved,
|
|
464
|
+
unlockedMasterKey: masterKeyForCaller
|
|
465
|
+
};
|
|
466
|
+
} finally {
|
|
467
|
+
clearInterval(heartbeatMonitor);
|
|
468
|
+
if (context.disconnectTimer) {
|
|
469
|
+
clearTimeout(context.disconnectTimer);
|
|
470
|
+
context.disconnectTimer = null;
|
|
471
|
+
}
|
|
472
|
+
wipeMasterKey$1(context.unlockedMasterKey);
|
|
473
|
+
context.unlockedMasterKey = null;
|
|
474
|
+
await closeServer(server);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
async function loadSessionHtml(mode) {
|
|
478
|
+
const distHtmlPath = path.resolve(getCliDistDirPath(), "session.html");
|
|
479
|
+
try {
|
|
480
|
+
return await readFile(distHtmlPath, "utf8");
|
|
481
|
+
} catch (error) {
|
|
482
|
+
if (isFileNotFoundError$1(error)) return renderFallbackPage(mode);
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
function getCliDistDirPath() {
|
|
487
|
+
const cliFilePath = fileURLToPath(import.meta.url);
|
|
488
|
+
return path.dirname(cliFilePath);
|
|
489
|
+
}
|
|
490
|
+
async function tryServeStaticDistAsset(pathname, response) {
|
|
491
|
+
const decodedPath = decodeURIComponent(pathname);
|
|
492
|
+
if (!decodedPath.startsWith("/")) return false;
|
|
493
|
+
const relativePath = decodedPath.slice(1);
|
|
494
|
+
if (!relativePath || relativePath.includes("\0")) return false;
|
|
495
|
+
const distDirPath = getCliDistDirPath();
|
|
496
|
+
const candidatePath = path.resolve(distDirPath, relativePath);
|
|
497
|
+
const normalizedDistPrefix = `${distDirPath}${path.sep}`;
|
|
498
|
+
if (!candidatePath.startsWith(normalizedDistPrefix)) return false;
|
|
499
|
+
try {
|
|
500
|
+
const content = await readFile(candidatePath);
|
|
501
|
+
response.writeHead(200, { "Content-Type": getContentTypeForPath(candidatePath) });
|
|
502
|
+
response.end(content);
|
|
503
|
+
return true;
|
|
504
|
+
} catch (error) {
|
|
505
|
+
if (isFileNotFoundError$1(error)) return false;
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
function getContentTypeForPath(filePath) {
|
|
510
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
511
|
+
if (extension === ".js" || extension === ".mjs") return "text/javascript; charset=utf-8";
|
|
512
|
+
if (extension === ".css") return "text/css; charset=utf-8";
|
|
513
|
+
if (extension === ".svg") return "image/svg+xml";
|
|
514
|
+
if (extension === ".json") return "application/json; charset=utf-8";
|
|
515
|
+
if (extension === ".html") return "text/html; charset=utf-8";
|
|
516
|
+
return "application/octet-stream";
|
|
517
|
+
}
|
|
518
|
+
function renderFallbackPage(mode) {
|
|
519
|
+
return `<!doctype html>
|
|
520
|
+
<html lang="en">
|
|
521
|
+
<head>
|
|
522
|
+
<meta charset="UTF-8" />
|
|
523
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
524
|
+
<title>rest-safe-env</title>
|
|
525
|
+
</head>
|
|
526
|
+
<body>
|
|
527
|
+
<main style="font-family: ui-sans-serif, system-ui, sans-serif; padding: 2rem;">
|
|
528
|
+
<h1>rest-safe-env</h1>
|
|
529
|
+
<p>mode: ${escapeHtml(mode)}</p>
|
|
530
|
+
<p>Built UI not found. Run <code>yarn build</code> and retry.</p>
|
|
531
|
+
</main>
|
|
532
|
+
</body>
|
|
533
|
+
</html>`;
|
|
534
|
+
}
|
|
535
|
+
async function listenOnFixedPort(server, port) {
|
|
536
|
+
await new Promise((resolve, reject) => {
|
|
537
|
+
const onError = (error) => {
|
|
538
|
+
server.off("listening", onListening);
|
|
539
|
+
reject(error);
|
|
540
|
+
};
|
|
541
|
+
const onListening = () => {
|
|
542
|
+
server.off("error", onError);
|
|
543
|
+
resolve();
|
|
544
|
+
};
|
|
545
|
+
server.once("error", onError);
|
|
546
|
+
server.once("listening", onListening);
|
|
547
|
+
server.listen(port, LOOPBACK_BIND_HOST);
|
|
548
|
+
}).catch((error) => {
|
|
549
|
+
if (isAddressInUseError(error)) throw new Error(`Configured UI port ${port} is already in use. Set a new one with: rse config port <newPort>.`);
|
|
550
|
+
throw error;
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
async function handleRequest(request, response, context) {
|
|
554
|
+
const host = request.headers.host ?? WEBAUTHN_RP_HOST;
|
|
555
|
+
const url = new URL(request.url ?? "/", `http://${host}`);
|
|
556
|
+
if (url.pathname === "/api/health") {
|
|
557
|
+
handleHealthRequest(request, response, context);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (url.pathname === "/api/session/ping") {
|
|
561
|
+
handleSessionPingRequest(request, response, context);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (url.pathname === "/api/session/closed") {
|
|
565
|
+
handleSessionClosedRequest(request, response, context, url);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (url.pathname === "/api/session/stream") {
|
|
569
|
+
handleSessionStreamRequest(request, response, context, url);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (url.pathname === "/api/env") {
|
|
573
|
+
await handleEnvRequest(request, response, context);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (url.pathname === "/api/env/decrypt") {
|
|
577
|
+
await handleDecryptEnvRequest(request, response, context);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (url.pathname === "/api/share/snapshot") {
|
|
581
|
+
await handleShareSnapshotRequest(request, response, context);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (url.pathname === "/api/register/request") {
|
|
585
|
+
handleRegisterRequest(request, response, context);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (url.pathname === "/api/register/response") {
|
|
589
|
+
await handleRegisterResponse(request, response, context);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (url.pathname === "/api/unlock/request") {
|
|
593
|
+
await handleUnlockRequest(request, response, context);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (url.pathname === "/api/unlock/response") {
|
|
597
|
+
await handleUnlockResponse(request, response, context);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (url.pathname === "/api/run/context") {
|
|
601
|
+
handleRunContextRequest(request, response, context);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (url.pathname === "/api/run/approve") {
|
|
605
|
+
handleRunApproveRequest(request, response, context);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (url.pathname === "/api/run/deny") {
|
|
609
|
+
handleRunDenyRequest(request, response, context);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (url.pathname === "/") {
|
|
613
|
+
const session = url.searchParams.get("session");
|
|
614
|
+
const token = url.searchParams.get("token");
|
|
615
|
+
const mode = url.searchParams.get("mode");
|
|
616
|
+
if (session !== context.sessionId || token !== context.token || mode !== context.mode) {
|
|
617
|
+
respondJson(response, 403, {
|
|
618
|
+
ok: false,
|
|
619
|
+
error: "forbidden"
|
|
620
|
+
});
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
624
|
+
response.end(context.pageHtml);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (url.pathname === "/favicon.ico") {
|
|
628
|
+
response.writeHead(204);
|
|
629
|
+
response.end();
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (request.method === "GET") {
|
|
633
|
+
if (await tryServeStaticDistAsset(url.pathname, response)) return;
|
|
634
|
+
}
|
|
635
|
+
respondJson(response, 404, {
|
|
636
|
+
ok: false,
|
|
637
|
+
error: "not_found"
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
function handleHealthRequest(request, response, context) {
|
|
641
|
+
if (request.method !== "GET") {
|
|
642
|
+
respondJson(response, 405, {
|
|
643
|
+
ok: false,
|
|
644
|
+
error: "method_not_allowed"
|
|
645
|
+
});
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (!hasValidToken(request, context.token)) {
|
|
649
|
+
respondJson(response, 403, {
|
|
650
|
+
ok: false,
|
|
651
|
+
error: "forbidden"
|
|
652
|
+
});
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
markSessionConnected(context);
|
|
656
|
+
respondJson(response, 200, { ok: true });
|
|
657
|
+
}
|
|
658
|
+
function handleSessionPingRequest(request, response, context) {
|
|
659
|
+
if (request.method !== "POST") {
|
|
660
|
+
respondJson(response, 405, {
|
|
661
|
+
ok: false,
|
|
662
|
+
error: "method_not_allowed"
|
|
663
|
+
});
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (!hasValidToken(request, context.token)) {
|
|
667
|
+
respondJson(response, 403, {
|
|
668
|
+
ok: false,
|
|
669
|
+
error: "forbidden"
|
|
670
|
+
});
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
markSessionConnected(context);
|
|
674
|
+
respondJson(response, 200, { ok: true });
|
|
675
|
+
}
|
|
676
|
+
function handleSessionStreamRequest(request, response, context, url) {
|
|
677
|
+
if (request.method !== "GET") {
|
|
678
|
+
respondJson(response, 405, {
|
|
679
|
+
ok: false,
|
|
680
|
+
error: "method_not_allowed"
|
|
681
|
+
});
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (url.searchParams.get("token") !== context.token) {
|
|
685
|
+
respondJson(response, 403, {
|
|
686
|
+
ok: false,
|
|
687
|
+
error: "forbidden"
|
|
688
|
+
});
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
response.writeHead(200, {
|
|
692
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
693
|
+
"Cache-Control": "no-cache, no-transform",
|
|
694
|
+
Connection: "keep-alive"
|
|
695
|
+
});
|
|
696
|
+
response.flushHeaders();
|
|
697
|
+
response.write("event: connected\ndata: ok\n\n");
|
|
698
|
+
markSessionConnected(context);
|
|
699
|
+
context.activeStreamCount += 1;
|
|
700
|
+
request.socket.setNoDelay(true);
|
|
701
|
+
const keepAliveId = setInterval(() => {
|
|
702
|
+
if (!response.writableEnded && !response.destroyed) response.write(": keepalive\n\n");
|
|
703
|
+
}, SESSION_STREAM_KEEPALIVE_MS);
|
|
704
|
+
let closed = false;
|
|
705
|
+
const onClose = () => {
|
|
706
|
+
if (closed) return;
|
|
707
|
+
closed = true;
|
|
708
|
+
clearInterval(keepAliveId);
|
|
709
|
+
context.activeStreamCount = Math.max(0, context.activeStreamCount - 1);
|
|
710
|
+
scheduleDisconnectCheck(context);
|
|
711
|
+
};
|
|
712
|
+
response.on("close", onClose);
|
|
713
|
+
response.on("error", onClose);
|
|
714
|
+
request.on("close", onClose);
|
|
715
|
+
request.socket.on("close", onClose);
|
|
716
|
+
request.socket.on("end", onClose);
|
|
717
|
+
}
|
|
718
|
+
function handleSessionClosedRequest(request, response, context, url) {
|
|
719
|
+
if (request.method !== "POST" && request.method !== "GET") {
|
|
720
|
+
respondJson(response, 405, {
|
|
721
|
+
ok: false,
|
|
722
|
+
error: "method_not_allowed"
|
|
723
|
+
});
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const tokenFromQuery = url.searchParams.get("token");
|
|
727
|
+
if (!hasValidToken(request, context.token) && tokenFromQuery !== context.token) {
|
|
728
|
+
respondJson(response, 403, {
|
|
729
|
+
ok: false,
|
|
730
|
+
error: "forbidden"
|
|
731
|
+
});
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
completeSessionFromWindowClose(context);
|
|
735
|
+
respondJson(response, 200, { ok: true });
|
|
736
|
+
}
|
|
737
|
+
function markSessionConnected(context) {
|
|
738
|
+
if (context.disconnectTimer) {
|
|
739
|
+
clearTimeout(context.disconnectTimer);
|
|
740
|
+
context.disconnectTimer = null;
|
|
741
|
+
}
|
|
742
|
+
context.connected = true;
|
|
743
|
+
context.lastHeartbeatAt = Date.now();
|
|
744
|
+
context.onConnected();
|
|
745
|
+
}
|
|
746
|
+
function scheduleDisconnectCheck(context) {
|
|
747
|
+
if (context.sessionCompleted || context.activeStreamCount > 0) return;
|
|
748
|
+
if (context.disconnectTimer) clearTimeout(context.disconnectTimer);
|
|
749
|
+
context.disconnectTimer = setTimeout(() => {
|
|
750
|
+
context.disconnectTimer = null;
|
|
751
|
+
if (context.activeStreamCount > 0 || context.sessionCompleted) return;
|
|
752
|
+
completeSessionFromWindowClose(context);
|
|
753
|
+
}, SESSION_STREAM_DISCONNECT_GRACE_MS);
|
|
754
|
+
}
|
|
755
|
+
async function handleEnvRequest(request, response, context) {
|
|
756
|
+
if (context.mode !== "view" && context.mode !== "import" || !context.viewState) {
|
|
757
|
+
respondJson(response, 404, {
|
|
758
|
+
ok: false,
|
|
759
|
+
error: "not_found"
|
|
760
|
+
});
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
if (!hasValidToken(request, context.token)) {
|
|
764
|
+
respondJson(response, 403, {
|
|
765
|
+
ok: false,
|
|
766
|
+
error: "forbidden"
|
|
767
|
+
});
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (request.method === "GET") {
|
|
771
|
+
respondJson(response, 200, { entries: toApiEntries(context.viewState.document) });
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
if (request.method !== "POST") {
|
|
775
|
+
respondJson(response, 405, {
|
|
776
|
+
ok: false,
|
|
777
|
+
error: "method_not_allowed"
|
|
778
|
+
});
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
try {
|
|
782
|
+
const payload = await readJsonBody(request);
|
|
783
|
+
if (!payload || !Array.isArray(payload.entries)) {
|
|
784
|
+
respondJson(response, 400, {
|
|
785
|
+
ok: false,
|
|
786
|
+
error: "invalid_payload"
|
|
787
|
+
});
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
context.viewState.document = applyEditableEntries(context.viewState.document, payload.entries, context.hasRegisteredCredentialMaterial, context.unlockedMasterKey);
|
|
791
|
+
const output = serializeEnvDocument(context.viewState.document);
|
|
792
|
+
await writeFile(context.viewState.envFilePath, output, "utf8");
|
|
793
|
+
wipeMasterKey$1(context.unlockedMasterKey);
|
|
794
|
+
context.unlockedMasterKey = null;
|
|
795
|
+
respondJson(response, 200, { ok: true });
|
|
796
|
+
context.onSessionComplete();
|
|
797
|
+
return;
|
|
798
|
+
} catch (error) {
|
|
799
|
+
if (error instanceof SessionApiError) {
|
|
800
|
+
respondJson(response, error.statusCode, {
|
|
801
|
+
ok: false,
|
|
802
|
+
error: error.errorCode,
|
|
803
|
+
message: error.message
|
|
804
|
+
});
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
respondJson(response, 400, {
|
|
808
|
+
ok: false,
|
|
809
|
+
error: "save_failed",
|
|
810
|
+
message: error instanceof Error ? error.message : "failed_to_save"
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
async function handleDecryptEnvRequest(request, response, context) {
|
|
815
|
+
if (request.method !== "POST") {
|
|
816
|
+
respondJson(response, 405, {
|
|
817
|
+
ok: false,
|
|
818
|
+
error: "method_not_allowed"
|
|
819
|
+
});
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (context.mode !== "view" || !context.viewState) {
|
|
823
|
+
respondJson(response, 404, {
|
|
824
|
+
ok: false,
|
|
825
|
+
error: "not_found"
|
|
826
|
+
});
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (!hasValidToken(request, context.token)) {
|
|
830
|
+
respondJson(response, 403, {
|
|
831
|
+
ok: false,
|
|
832
|
+
error: "forbidden"
|
|
833
|
+
});
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (!context.hasRegisteredCredentialMaterial) {
|
|
837
|
+
respondJson(response, 409, {
|
|
838
|
+
ok: false,
|
|
839
|
+
error: "registration_required",
|
|
840
|
+
message: "WebAuthn registration is required before decrypting values."
|
|
841
|
+
});
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (!context.unlockedMasterKey) {
|
|
845
|
+
respondJson(response, 409, {
|
|
846
|
+
ok: false,
|
|
847
|
+
error: "unlock_required",
|
|
848
|
+
message: "Unlock required before decrypting values."
|
|
849
|
+
});
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
try {
|
|
853
|
+
const payload = await readJsonBody(request);
|
|
854
|
+
if (!payload || !Number.isInteger(payload.sourceIndex) || payload.sourceIndex < 0 || typeof payload.key !== "string" || payload.key.length === 0) throw new SessionApiError(400, "invalid_payload", "sourceIndex and key are required.");
|
|
855
|
+
const sourceEntry = context.viewState.document.entries[payload.sourceIndex];
|
|
856
|
+
if (!sourceEntry || sourceEntry.type !== "pair" || sourceEntry.key !== payload.key || !sourceEntry.rawValue.startsWith("enc:v1:")) throw new SessionApiError(404, "entry_not_found", "Encrypted entry was not found.");
|
|
857
|
+
respondJson(response, 200, { value: decryptEnvValue(sourceEntry.rawValue, sourceEntry.key, context.unlockedMasterKey) });
|
|
858
|
+
} catch (error) {
|
|
859
|
+
if (error instanceof SessionApiError) {
|
|
860
|
+
respondJson(response, error.statusCode, {
|
|
861
|
+
ok: false,
|
|
862
|
+
error: error.errorCode,
|
|
863
|
+
message: error.message
|
|
864
|
+
});
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
respondJson(response, 400, {
|
|
868
|
+
ok: false,
|
|
869
|
+
error: "decrypt_failed",
|
|
870
|
+
message: error instanceof Error ? error.message : "decrypt_failed"
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
async function handleShareSnapshotRequest(request, response, context) {
|
|
875
|
+
if (request.method !== "POST") {
|
|
876
|
+
respondJson(response, 405, {
|
|
877
|
+
ok: false,
|
|
878
|
+
error: "method_not_allowed"
|
|
879
|
+
});
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (context.mode !== "view" || !context.viewState) {
|
|
883
|
+
respondJson(response, 404, {
|
|
884
|
+
ok: false,
|
|
885
|
+
error: "not_found"
|
|
886
|
+
});
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
if (!hasValidToken(request, context.token)) {
|
|
890
|
+
respondJson(response, 403, {
|
|
891
|
+
ok: false,
|
|
892
|
+
error: "forbidden"
|
|
893
|
+
});
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
try {
|
|
897
|
+
respondJson(response, 200, { entries: buildShareSnapshotEntries(context.viewState.document, context.hasRegisteredCredentialMaterial, context.unlockedMasterKey) });
|
|
898
|
+
} catch (error) {
|
|
899
|
+
if (error instanceof SessionApiError) {
|
|
900
|
+
respondJson(response, error.statusCode, {
|
|
901
|
+
ok: false,
|
|
902
|
+
error: error.errorCode,
|
|
903
|
+
message: error.message
|
|
904
|
+
});
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
respondJson(response, 400, {
|
|
908
|
+
ok: false,
|
|
909
|
+
error: "share_snapshot_failed",
|
|
910
|
+
message: error instanceof Error ? error.message : "share_snapshot_failed"
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
function handleRegisterRequest(request, response, context) {
|
|
915
|
+
if (request.method !== "POST") {
|
|
916
|
+
respondJson(response, 405, {
|
|
917
|
+
ok: false,
|
|
918
|
+
error: "method_not_allowed"
|
|
919
|
+
});
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
if (context.mode !== "view" && context.mode !== "import") {
|
|
923
|
+
respondJson(response, 404, {
|
|
924
|
+
ok: false,
|
|
925
|
+
error: "not_found"
|
|
926
|
+
});
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
if (!hasValidToken(request, context.token)) {
|
|
930
|
+
respondJson(response, 403, {
|
|
931
|
+
ok: false,
|
|
932
|
+
error: "forbidden"
|
|
933
|
+
});
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
if (context.hasRegisteredCredentialMaterial) {
|
|
937
|
+
respondJson(response, 200, {
|
|
938
|
+
ok: true,
|
|
939
|
+
alreadyRegistered: true
|
|
940
|
+
});
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
const challenge = randomBytes(32);
|
|
944
|
+
const userId = randomBytes(16);
|
|
945
|
+
const rpId = WEBAUTHN_RP_HOST;
|
|
946
|
+
context.pendingRegistration = {
|
|
947
|
+
challenge,
|
|
948
|
+
rpId,
|
|
949
|
+
expectedOrigin: `http://${WEBAUTHN_RP_HOST}:${context.port}`,
|
|
950
|
+
expiresAt: Date.now() + 6e4
|
|
951
|
+
};
|
|
952
|
+
respondJson(response, 200, {
|
|
953
|
+
challenge: challenge.toString("base64"),
|
|
954
|
+
rpId,
|
|
955
|
+
user: {
|
|
956
|
+
id: userId.toString("base64"),
|
|
957
|
+
name: "local-user"
|
|
958
|
+
},
|
|
959
|
+
prfSalt: getPrfSaltBase64()
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
async function handleRegisterResponse(request, response, context) {
|
|
963
|
+
if (request.method !== "POST") {
|
|
964
|
+
respondJson(response, 405, {
|
|
965
|
+
ok: false,
|
|
966
|
+
error: "method_not_allowed"
|
|
967
|
+
});
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (context.mode !== "view" && context.mode !== "import") {
|
|
971
|
+
respondJson(response, 404, {
|
|
972
|
+
ok: false,
|
|
973
|
+
error: "not_found"
|
|
974
|
+
});
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (!hasValidToken(request, context.token)) {
|
|
978
|
+
respondJson(response, 403, {
|
|
979
|
+
ok: false,
|
|
980
|
+
error: "forbidden"
|
|
981
|
+
});
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (!context.pendingRegistration) {
|
|
985
|
+
respondJson(response, 400, {
|
|
986
|
+
ok: false,
|
|
987
|
+
error: "registration_not_requested"
|
|
988
|
+
});
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
try {
|
|
992
|
+
const payload = await readJsonBody(request);
|
|
993
|
+
validateRegisterResponsePayload(payload);
|
|
994
|
+
if (Date.now() > context.pendingRegistration.expiresAt) {
|
|
995
|
+
context.pendingRegistration = null;
|
|
996
|
+
throw new SessionApiError(400, "registration_expired", "Registration request expired.");
|
|
997
|
+
}
|
|
998
|
+
const clientData = parseClientData(payload.clientDataJSON);
|
|
999
|
+
const expectedChallenge = toBase64Url(context.pendingRegistration.challenge);
|
|
1000
|
+
if (clientData.challenge !== expectedChallenge) throw new SessionApiError(400, "invalid_registration_challenge", "Registration challenge mismatch.");
|
|
1001
|
+
if (clientData.type !== "webauthn.create") throw new SessionApiError(400, "invalid_registration_type", "Unexpected WebAuthn clientData type.");
|
|
1002
|
+
if (clientData.origin !== context.pendingRegistration.expectedOrigin) throw new SessionApiError(400, "invalid_registration_origin", "Unexpected registration origin.");
|
|
1003
|
+
const registrationResult = await registerCredentialAndMasterKey({
|
|
1004
|
+
credentialId: payload.id,
|
|
1005
|
+
rpId: context.pendingRegistration.rpId,
|
|
1006
|
+
publicKeySpki: payload.publicKeySpki,
|
|
1007
|
+
clientDataJSON: payload.clientDataJSON,
|
|
1008
|
+
attestationObject: payload.attestationObject,
|
|
1009
|
+
prfOutput: payload.prfOutput
|
|
1010
|
+
});
|
|
1011
|
+
context.hasRegisteredCredentialMaterial = true;
|
|
1012
|
+
context.unlockedMasterKey = registrationResult.masterKey;
|
|
1013
|
+
context.pendingRegistration = null;
|
|
1014
|
+
respondJson(response, 200, { ok: true });
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
context.pendingRegistration = null;
|
|
1017
|
+
if (error instanceof SessionApiError) {
|
|
1018
|
+
respondJson(response, error.statusCode, {
|
|
1019
|
+
ok: false,
|
|
1020
|
+
error: error.errorCode,
|
|
1021
|
+
message: error.message
|
|
1022
|
+
});
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
respondJson(response, 400, {
|
|
1026
|
+
ok: false,
|
|
1027
|
+
error: "registration_failed",
|
|
1028
|
+
message: error instanceof Error ? error.message : "registration_failed"
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
async function handleUnlockRequest(request, response, context) {
|
|
1033
|
+
if (request.method !== "POST") {
|
|
1034
|
+
respondJson(response, 405, {
|
|
1035
|
+
ok: false,
|
|
1036
|
+
error: "method_not_allowed"
|
|
1037
|
+
});
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
if (!hasValidToken(request, context.token)) {
|
|
1041
|
+
respondJson(response, 403, {
|
|
1042
|
+
ok: false,
|
|
1043
|
+
error: "forbidden"
|
|
1044
|
+
});
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
if (context.unlockedMasterKey) {
|
|
1048
|
+
respondJson(response, 200, {
|
|
1049
|
+
ok: true,
|
|
1050
|
+
alreadyUnlocked: true
|
|
1051
|
+
});
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
if (!context.hasRegisteredCredentialMaterial) {
|
|
1055
|
+
respondJson(response, 409, {
|
|
1056
|
+
ok: false,
|
|
1057
|
+
error: "registration_required",
|
|
1058
|
+
message: "WebAuthn registration is required before unlock."
|
|
1059
|
+
});
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
const credential = await readCredentialSummary();
|
|
1063
|
+
if (!credential) {
|
|
1064
|
+
respondJson(response, 409, {
|
|
1065
|
+
ok: false,
|
|
1066
|
+
error: "registration_required",
|
|
1067
|
+
message: "Credential material missing. Run registration again."
|
|
1068
|
+
});
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
const challenge = randomBytes(32);
|
|
1072
|
+
const expectedOrigin = `http://${WEBAUTHN_RP_HOST}:${context.port}`;
|
|
1073
|
+
context.pendingUnlock = {
|
|
1074
|
+
challenge,
|
|
1075
|
+
expiresAt: Date.now() + 6e4,
|
|
1076
|
+
rpId: credential.rpId,
|
|
1077
|
+
expectedOrigin,
|
|
1078
|
+
credentialId: credential.credentialId
|
|
1079
|
+
};
|
|
1080
|
+
respondJson(response, 200, {
|
|
1081
|
+
challenge: challenge.toString("base64"),
|
|
1082
|
+
credentialId: credential.credentialId,
|
|
1083
|
+
rpId: credential.rpId,
|
|
1084
|
+
timeoutMs: 6e4,
|
|
1085
|
+
prfSalt: getPrfSaltBase64()
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
async function handleUnlockResponse(request, response, context) {
|
|
1089
|
+
if (request.method !== "POST") {
|
|
1090
|
+
respondJson(response, 405, {
|
|
1091
|
+
ok: false,
|
|
1092
|
+
error: "method_not_allowed"
|
|
1093
|
+
});
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
if (!hasValidToken(request, context.token)) {
|
|
1097
|
+
respondJson(response, 403, {
|
|
1098
|
+
ok: false,
|
|
1099
|
+
error: "forbidden"
|
|
1100
|
+
});
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
if (!context.pendingUnlock) {
|
|
1104
|
+
respondJson(response, 400, {
|
|
1105
|
+
ok: false,
|
|
1106
|
+
error: "unlock_not_requested"
|
|
1107
|
+
});
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
try {
|
|
1111
|
+
const payload = await readJsonBody(request);
|
|
1112
|
+
validateUnlockResponsePayload(payload);
|
|
1113
|
+
if (Date.now() > context.pendingUnlock.expiresAt) {
|
|
1114
|
+
context.pendingUnlock = null;
|
|
1115
|
+
throw new SessionApiError(400, "unlock_expired", "Unlock request expired.");
|
|
1116
|
+
}
|
|
1117
|
+
if (payload.id !== context.pendingUnlock.credentialId) throw new SessionApiError(400, "invalid_unlock_credential", "Credential mismatch for unlock.");
|
|
1118
|
+
const clientData = parseClientData(payload.clientDataJSON);
|
|
1119
|
+
const expectedChallenge = toBase64Url(context.pendingUnlock.challenge);
|
|
1120
|
+
if (clientData.challenge !== expectedChallenge) throw new SessionApiError(400, "invalid_unlock_challenge", "Unlock challenge mismatch.");
|
|
1121
|
+
if (clientData.type !== "webauthn.get") throw new SessionApiError(400, "invalid_unlock_type", "Unexpected WebAuthn clientData type.");
|
|
1122
|
+
if (clientData.origin !== context.pendingUnlock.expectedOrigin) throw new SessionApiError(400, "invalid_unlock_origin", "Unexpected unlock origin.");
|
|
1123
|
+
const credential = await readCredentialSummary();
|
|
1124
|
+
if (!credential) throw new SessionApiError(400, "credential_not_found", "Credential material not found.");
|
|
1125
|
+
if (credential.credentialId !== context.pendingUnlock.credentialId) throw new SessionApiError(400, "invalid_unlock_credential", "Credential mismatch for unlock.");
|
|
1126
|
+
const authenticatorData = decodeBase64(payload.authenticatorData);
|
|
1127
|
+
const signCount = verifyAuthenticatorData(authenticatorData, credential.rpId);
|
|
1128
|
+
if (!verifyWebAuthnAssertionSignature(credential.publicKeySpki, authenticatorData, payload.clientDataJSON, payload.signature)) throw new SessionApiError(400, "invalid_unlock_signature", "Assertion signature verification failed.");
|
|
1129
|
+
if (credential.signCount > 0 && signCount > 0 && signCount <= credential.signCount) throw new SessionApiError(400, "sign_count_regression", "Authenticator sign counter did not increase; possible cloned credential.");
|
|
1130
|
+
if (signCount > credential.signCount) await updateCredentialSignCount(signCount);
|
|
1131
|
+
context.unlockedMasterKey = await unwrapMasterKeyFromPrfOutput(payload.prfOutput);
|
|
1132
|
+
context.pendingUnlock = null;
|
|
1133
|
+
respondJson(response, 200, { ok: true });
|
|
1134
|
+
} catch (error) {
|
|
1135
|
+
context.pendingUnlock = null;
|
|
1136
|
+
if (error instanceof SessionApiError) {
|
|
1137
|
+
respondJson(response, error.statusCode, {
|
|
1138
|
+
ok: false,
|
|
1139
|
+
error: error.errorCode,
|
|
1140
|
+
message: error.message
|
|
1141
|
+
});
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
respondJson(response, 400, {
|
|
1145
|
+
ok: false,
|
|
1146
|
+
error: "unlock_failed",
|
|
1147
|
+
message: error instanceof Error ? error.message : "unlock_failed"
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
function handleRunContextRequest(request, response, context) {
|
|
1152
|
+
if (request.method !== "GET") {
|
|
1153
|
+
respondJson(response, 405, {
|
|
1154
|
+
ok: false,
|
|
1155
|
+
error: "method_not_allowed"
|
|
1156
|
+
});
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (context.mode !== "run" || !context.runState) {
|
|
1160
|
+
respondJson(response, 404, {
|
|
1161
|
+
ok: false,
|
|
1162
|
+
error: "not_found"
|
|
1163
|
+
});
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
if (!hasValidToken(request, context.token)) {
|
|
1167
|
+
respondJson(response, 403, {
|
|
1168
|
+
ok: false,
|
|
1169
|
+
error: "forbidden"
|
|
1170
|
+
});
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
respondJson(response, 200, {
|
|
1174
|
+
command: context.runState.commandDisplay,
|
|
1175
|
+
envFilePath: context.runState.envFilePath,
|
|
1176
|
+
encryptedEntryCount: context.runState.encryptedEntryCount,
|
|
1177
|
+
requiresUnlock: context.runState.requiresUnlock
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
function handleRunApproveRequest(request, response, context) {
|
|
1181
|
+
if (request.method !== "POST") {
|
|
1182
|
+
respondJson(response, 405, {
|
|
1183
|
+
ok: false,
|
|
1184
|
+
error: "method_not_allowed"
|
|
1185
|
+
});
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
if (context.mode !== "run" || !context.runState) {
|
|
1189
|
+
respondJson(response, 404, {
|
|
1190
|
+
ok: false,
|
|
1191
|
+
error: "not_found"
|
|
1192
|
+
});
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
if (!hasValidToken(request, context.token)) {
|
|
1196
|
+
respondJson(response, 403, {
|
|
1197
|
+
ok: false,
|
|
1198
|
+
error: "forbidden"
|
|
1199
|
+
});
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
if (context.runState.decided) {
|
|
1203
|
+
respondJson(response, 409, {
|
|
1204
|
+
ok: false,
|
|
1205
|
+
error: "already_decided"
|
|
1206
|
+
});
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
if (context.runState.requiresUnlock && !context.unlockedMasterKey) {
|
|
1210
|
+
respondJson(response, 409, {
|
|
1211
|
+
ok: false,
|
|
1212
|
+
error: "unlock_required",
|
|
1213
|
+
message: "Unlock is required before approving this run."
|
|
1214
|
+
});
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
context.runState.decided = true;
|
|
1218
|
+
context.onRunDecision?.(true);
|
|
1219
|
+
context.onSessionComplete();
|
|
1220
|
+
respondJson(response, 200, { approved: true });
|
|
1221
|
+
}
|
|
1222
|
+
function handleRunDenyRequest(request, response, context) {
|
|
1223
|
+
if (request.method !== "POST") {
|
|
1224
|
+
respondJson(response, 405, {
|
|
1225
|
+
ok: false,
|
|
1226
|
+
error: "method_not_allowed"
|
|
1227
|
+
});
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
if (context.mode !== "run" || !context.runState) {
|
|
1231
|
+
respondJson(response, 404, {
|
|
1232
|
+
ok: false,
|
|
1233
|
+
error: "not_found"
|
|
1234
|
+
});
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
if (!hasValidToken(request, context.token)) {
|
|
1238
|
+
respondJson(response, 403, {
|
|
1239
|
+
ok: false,
|
|
1240
|
+
error: "forbidden"
|
|
1241
|
+
});
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
if (!context.runState.decided) {
|
|
1245
|
+
context.runState.decided = true;
|
|
1246
|
+
context.onRunDecision?.(false);
|
|
1247
|
+
context.onSessionComplete();
|
|
1248
|
+
}
|
|
1249
|
+
respondJson(response, 200, { approved: false });
|
|
1250
|
+
}
|
|
1251
|
+
function toApiEntries(document) {
|
|
1252
|
+
return document.entries.map((entry) => {
|
|
1253
|
+
if (entry.type === "blank") return { type: "blank" };
|
|
1254
|
+
if (entry.type === "comment") return {
|
|
1255
|
+
type: "comment",
|
|
1256
|
+
text: entry.text
|
|
1257
|
+
};
|
|
1258
|
+
if (entry.rawValue.startsWith("enc:v1:")) return {
|
|
1259
|
+
type: "pair",
|
|
1260
|
+
key: entry.key,
|
|
1261
|
+
value: { kind: "encrypted" }
|
|
1262
|
+
};
|
|
1263
|
+
return {
|
|
1264
|
+
type: "pair",
|
|
1265
|
+
key: entry.key,
|
|
1266
|
+
value: {
|
|
1267
|
+
kind: "plain",
|
|
1268
|
+
value: decodeEnvValue(entry.rawValue)
|
|
1269
|
+
}
|
|
1270
|
+
};
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
function buildShareSnapshotEntries(document, hasCredentialMaterial, unlockedMasterKey) {
|
|
1274
|
+
return document.entries.map((entry) => {
|
|
1275
|
+
if (entry.type === "blank") return { type: "blank" };
|
|
1276
|
+
if (entry.type === "comment") return {
|
|
1277
|
+
type: "comment",
|
|
1278
|
+
text: entry.text
|
|
1279
|
+
};
|
|
1280
|
+
if (!entry.rawValue.startsWith("enc:v1:")) return {
|
|
1281
|
+
type: "pair",
|
|
1282
|
+
key: entry.key,
|
|
1283
|
+
value: decodeEnvValue(entry.rawValue),
|
|
1284
|
+
encrypt: false
|
|
1285
|
+
};
|
|
1286
|
+
if (!hasCredentialMaterial) throw new SessionApiError(409, "registration_required", "WebAuthn registration is required before sharing encrypted values.");
|
|
1287
|
+
if (!unlockedMasterKey) throw new SessionApiError(409, "unlock_required", "Unlock required before sharing encrypted values.");
|
|
1288
|
+
return {
|
|
1289
|
+
type: "pair",
|
|
1290
|
+
key: entry.key,
|
|
1291
|
+
value: decryptEnvValue(entry.rawValue, entry.key, unlockedMasterKey),
|
|
1292
|
+
encrypt: true
|
|
1293
|
+
};
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
function applyEditableEntries(document, entries, hasCredentialMaterial, unlockedMasterKey) {
|
|
1297
|
+
const nextEntries = entries.map((entry, index) => {
|
|
1298
|
+
if (entry.type === "blank") return {
|
|
1299
|
+
type: "blank",
|
|
1300
|
+
whitespace: ""
|
|
1301
|
+
};
|
|
1302
|
+
if (entry.type === "comment") return {
|
|
1303
|
+
type: "comment",
|
|
1304
|
+
text: entry.text
|
|
1305
|
+
};
|
|
1306
|
+
if (entry.encrypt) {
|
|
1307
|
+
const sourceIndex$1 = Number.isInteger(entry.sourceIndex) && (entry.sourceIndex ?? -1) >= 0 ? entry.sourceIndex : index;
|
|
1308
|
+
const previousEntry$1 = document.entries[sourceIndex$1];
|
|
1309
|
+
if (entry.preserveEncrypted === true && previousEntry$1 && previousEntry$1.type === "pair" && previousEntry$1.key === entry.key && previousEntry$1.rawValue.startsWith("enc:v1:")) return {
|
|
1310
|
+
type: "pair",
|
|
1311
|
+
key: entry.key,
|
|
1312
|
+
rawValue: previousEntry$1.rawValue,
|
|
1313
|
+
leadingWhitespace: "",
|
|
1314
|
+
spacingBeforeEquals: "",
|
|
1315
|
+
spacingAfterEquals: "",
|
|
1316
|
+
encrypted: true
|
|
1317
|
+
};
|
|
1318
|
+
if (!hasCredentialMaterial) throw new SessionApiError(409, "registration_required", "WebAuthn registration is required before encrypting values.");
|
|
1319
|
+
if (!unlockedMasterKey) throw new SessionApiError(409, "unlock_required", "Unlock required before encrypting values.");
|
|
1320
|
+
return {
|
|
1321
|
+
type: "pair",
|
|
1322
|
+
key: entry.key,
|
|
1323
|
+
rawValue: encryptEnvValue(decodeEnvValue(entry.value), entry.key, unlockedMasterKey),
|
|
1324
|
+
leadingWhitespace: "",
|
|
1325
|
+
spacingBeforeEquals: "",
|
|
1326
|
+
spacingAfterEquals: "",
|
|
1327
|
+
encrypted: true
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
const sourceIndex = Number.isInteger(entry.sourceIndex) && (entry.sourceIndex ?? -1) >= 0 ? entry.sourceIndex : index;
|
|
1331
|
+
const previousEntry = document.entries[sourceIndex];
|
|
1332
|
+
const canPreservePlainRaw = entry.preservePlainRaw === true && previousEntry && previousEntry.type === "pair" && previousEntry.key === entry.key && !previousEntry.rawValue.startsWith("enc:v1:");
|
|
1333
|
+
return {
|
|
1334
|
+
type: "pair",
|
|
1335
|
+
key: entry.key,
|
|
1336
|
+
rawValue: canPreservePlainRaw ? previousEntry.rawValue : entry.value,
|
|
1337
|
+
leadingWhitespace: "",
|
|
1338
|
+
spacingBeforeEquals: "",
|
|
1339
|
+
spacingAfterEquals: "",
|
|
1340
|
+
encrypted: false
|
|
1341
|
+
};
|
|
1342
|
+
});
|
|
1343
|
+
return {
|
|
1344
|
+
...document,
|
|
1345
|
+
entries: nextEntries
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
function hasValidToken(request, expectedToken) {
|
|
1349
|
+
const tokenHeader = request.headers["x-rse-token"];
|
|
1350
|
+
if (typeof tokenHeader !== "string") return false;
|
|
1351
|
+
return tokenHeader === expectedToken;
|
|
1352
|
+
}
|
|
1353
|
+
async function readJsonBody(request) {
|
|
1354
|
+
const chunks = [];
|
|
1355
|
+
await new Promise((resolve, reject) => {
|
|
1356
|
+
request.on("data", (chunk) => {
|
|
1357
|
+
chunks.push(chunk);
|
|
1358
|
+
});
|
|
1359
|
+
request.on("end", () => resolve());
|
|
1360
|
+
request.on("error", reject);
|
|
1361
|
+
});
|
|
1362
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
1363
|
+
return JSON.parse(text);
|
|
1364
|
+
}
|
|
1365
|
+
function respondJson(response, statusCode, body) {
|
|
1366
|
+
response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
|
|
1367
|
+
response.end(JSON.stringify(body));
|
|
1368
|
+
}
|
|
1369
|
+
async function openBrowser(url) {
|
|
1370
|
+
if (process.env.RSE_SKIP_BROWSER_OPEN === "1") return;
|
|
1371
|
+
const launchCommands = buildBrowserLaunchCommands(url);
|
|
1372
|
+
for (const launchCommand of launchCommands) if (await tryLaunchDetachedProcess(launchCommand.cmd, launchCommand.args)) return;
|
|
1373
|
+
throw new Error("Failed to launch browser.");
|
|
1374
|
+
}
|
|
1375
|
+
function buildBrowserLaunchCommands(url) {
|
|
1376
|
+
if (process.platform === "darwin") return [
|
|
1377
|
+
{
|
|
1378
|
+
cmd: "open",
|
|
1379
|
+
args: [
|
|
1380
|
+
"-na",
|
|
1381
|
+
"Google Chrome",
|
|
1382
|
+
"--args",
|
|
1383
|
+
`--app=${url}`
|
|
1384
|
+
]
|
|
1385
|
+
},
|
|
1386
|
+
{
|
|
1387
|
+
cmd: "open",
|
|
1388
|
+
args: [
|
|
1389
|
+
"-na",
|
|
1390
|
+
"Microsoft Edge",
|
|
1391
|
+
"--args",
|
|
1392
|
+
`--app=${url}`
|
|
1393
|
+
]
|
|
1394
|
+
},
|
|
1395
|
+
{
|
|
1396
|
+
cmd: "open",
|
|
1397
|
+
args: [
|
|
1398
|
+
"-na",
|
|
1399
|
+
"Brave Browser",
|
|
1400
|
+
"--args",
|
|
1401
|
+
`--app=${url}`
|
|
1402
|
+
]
|
|
1403
|
+
},
|
|
1404
|
+
{
|
|
1405
|
+
cmd: "open",
|
|
1406
|
+
args: [
|
|
1407
|
+
"-na",
|
|
1408
|
+
"Google Chrome",
|
|
1409
|
+
"--args",
|
|
1410
|
+
"--new-window",
|
|
1411
|
+
url
|
|
1412
|
+
]
|
|
1413
|
+
},
|
|
1414
|
+
{
|
|
1415
|
+
cmd: "open",
|
|
1416
|
+
args: [
|
|
1417
|
+
"-na",
|
|
1418
|
+
"Microsoft Edge",
|
|
1419
|
+
"--args",
|
|
1420
|
+
"--new-window",
|
|
1421
|
+
url
|
|
1422
|
+
]
|
|
1423
|
+
},
|
|
1424
|
+
{
|
|
1425
|
+
cmd: "open",
|
|
1426
|
+
args: [
|
|
1427
|
+
"-na",
|
|
1428
|
+
"Brave Browser",
|
|
1429
|
+
"--args",
|
|
1430
|
+
"--new-window",
|
|
1431
|
+
url
|
|
1432
|
+
]
|
|
1433
|
+
},
|
|
1434
|
+
{
|
|
1435
|
+
cmd: "open",
|
|
1436
|
+
args: [
|
|
1437
|
+
"-na",
|
|
1438
|
+
"Firefox",
|
|
1439
|
+
"--args",
|
|
1440
|
+
"-new-window",
|
|
1441
|
+
url
|
|
1442
|
+
]
|
|
1443
|
+
},
|
|
1444
|
+
{
|
|
1445
|
+
cmd: "open",
|
|
1446
|
+
args: [
|
|
1447
|
+
"-na",
|
|
1448
|
+
"Safari",
|
|
1449
|
+
url
|
|
1450
|
+
]
|
|
1451
|
+
},
|
|
1452
|
+
{
|
|
1453
|
+
cmd: "open",
|
|
1454
|
+
args: [url]
|
|
1455
|
+
}
|
|
1456
|
+
];
|
|
1457
|
+
if (process.platform === "win32") return [
|
|
1458
|
+
{
|
|
1459
|
+
cmd: "cmd",
|
|
1460
|
+
args: [
|
|
1461
|
+
"/c",
|
|
1462
|
+
"start",
|
|
1463
|
+
"",
|
|
1464
|
+
"chrome",
|
|
1465
|
+
`--app=${url}`
|
|
1466
|
+
]
|
|
1467
|
+
},
|
|
1468
|
+
{
|
|
1469
|
+
cmd: "cmd",
|
|
1470
|
+
args: [
|
|
1471
|
+
"/c",
|
|
1472
|
+
"start",
|
|
1473
|
+
"",
|
|
1474
|
+
"msedge",
|
|
1475
|
+
`--app=${url}`
|
|
1476
|
+
]
|
|
1477
|
+
},
|
|
1478
|
+
{
|
|
1479
|
+
cmd: "cmd",
|
|
1480
|
+
args: [
|
|
1481
|
+
"/c",
|
|
1482
|
+
"start",
|
|
1483
|
+
"",
|
|
1484
|
+
"brave",
|
|
1485
|
+
`--app=${url}`
|
|
1486
|
+
]
|
|
1487
|
+
},
|
|
1488
|
+
{
|
|
1489
|
+
cmd: "cmd",
|
|
1490
|
+
args: [
|
|
1491
|
+
"/c",
|
|
1492
|
+
"start",
|
|
1493
|
+
"",
|
|
1494
|
+
"chrome",
|
|
1495
|
+
"--new-window",
|
|
1496
|
+
url
|
|
1497
|
+
]
|
|
1498
|
+
},
|
|
1499
|
+
{
|
|
1500
|
+
cmd: "cmd",
|
|
1501
|
+
args: [
|
|
1502
|
+
"/c",
|
|
1503
|
+
"start",
|
|
1504
|
+
"",
|
|
1505
|
+
"msedge",
|
|
1506
|
+
"--new-window",
|
|
1507
|
+
url
|
|
1508
|
+
]
|
|
1509
|
+
},
|
|
1510
|
+
{
|
|
1511
|
+
cmd: "cmd",
|
|
1512
|
+
args: [
|
|
1513
|
+
"/c",
|
|
1514
|
+
"start",
|
|
1515
|
+
"",
|
|
1516
|
+
"brave",
|
|
1517
|
+
"--new-window",
|
|
1518
|
+
url
|
|
1519
|
+
]
|
|
1520
|
+
},
|
|
1521
|
+
{
|
|
1522
|
+
cmd: "cmd",
|
|
1523
|
+
args: [
|
|
1524
|
+
"/c",
|
|
1525
|
+
"start",
|
|
1526
|
+
"",
|
|
1527
|
+
"firefox",
|
|
1528
|
+
"-new-window",
|
|
1529
|
+
url
|
|
1530
|
+
]
|
|
1531
|
+
},
|
|
1532
|
+
{
|
|
1533
|
+
cmd: "cmd",
|
|
1534
|
+
args: [
|
|
1535
|
+
"/c",
|
|
1536
|
+
"start",
|
|
1537
|
+
"",
|
|
1538
|
+
url
|
|
1539
|
+
]
|
|
1540
|
+
}
|
|
1541
|
+
];
|
|
1542
|
+
return [
|
|
1543
|
+
{
|
|
1544
|
+
cmd: "google-chrome",
|
|
1545
|
+
args: [`--app=${url}`]
|
|
1546
|
+
},
|
|
1547
|
+
{
|
|
1548
|
+
cmd: "chromium-browser",
|
|
1549
|
+
args: [`--app=${url}`]
|
|
1550
|
+
},
|
|
1551
|
+
{
|
|
1552
|
+
cmd: "chromium",
|
|
1553
|
+
args: [`--app=${url}`]
|
|
1554
|
+
},
|
|
1555
|
+
{
|
|
1556
|
+
cmd: "microsoft-edge",
|
|
1557
|
+
args: [`--app=${url}`]
|
|
1558
|
+
},
|
|
1559
|
+
{
|
|
1560
|
+
cmd: "brave-browser",
|
|
1561
|
+
args: [`--app=${url}`]
|
|
1562
|
+
},
|
|
1563
|
+
{
|
|
1564
|
+
cmd: "google-chrome",
|
|
1565
|
+
args: ["--new-window", url]
|
|
1566
|
+
},
|
|
1567
|
+
{
|
|
1568
|
+
cmd: "chromium-browser",
|
|
1569
|
+
args: ["--new-window", url]
|
|
1570
|
+
},
|
|
1571
|
+
{
|
|
1572
|
+
cmd: "chromium",
|
|
1573
|
+
args: ["--new-window", url]
|
|
1574
|
+
},
|
|
1575
|
+
{
|
|
1576
|
+
cmd: "microsoft-edge",
|
|
1577
|
+
args: ["--new-window", url]
|
|
1578
|
+
},
|
|
1579
|
+
{
|
|
1580
|
+
cmd: "brave-browser",
|
|
1581
|
+
args: ["--new-window", url]
|
|
1582
|
+
},
|
|
1583
|
+
{
|
|
1584
|
+
cmd: "firefox",
|
|
1585
|
+
args: ["--new-window", url]
|
|
1586
|
+
},
|
|
1587
|
+
{
|
|
1588
|
+
cmd: "xdg-open",
|
|
1589
|
+
args: [url]
|
|
1590
|
+
}
|
|
1591
|
+
];
|
|
1592
|
+
}
|
|
1593
|
+
async function tryLaunchDetachedProcess(cmd, args) {
|
|
1594
|
+
return await new Promise((resolve) => {
|
|
1595
|
+
const child = spawn(cmd, args, {
|
|
1596
|
+
detached: true,
|
|
1597
|
+
stdio: "ignore"
|
|
1598
|
+
});
|
|
1599
|
+
let done = false;
|
|
1600
|
+
const finalize = (result) => {
|
|
1601
|
+
if (done) return;
|
|
1602
|
+
done = true;
|
|
1603
|
+
resolve(result);
|
|
1604
|
+
};
|
|
1605
|
+
child.once("error", () => finalize(false));
|
|
1606
|
+
child.unref();
|
|
1607
|
+
setTimeout(() => finalize(true), 120);
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
function waitForConnection(connectedPromise, timeoutMs) {
|
|
1611
|
+
return new Promise((resolve, reject) => {
|
|
1612
|
+
const timeoutId = setTimeout(() => {
|
|
1613
|
+
reject(/* @__PURE__ */ new Error(`Timed out waiting for browser connection after ${timeoutMs}ms.`));
|
|
1614
|
+
}, timeoutMs);
|
|
1615
|
+
connectedPromise.then(() => {
|
|
1616
|
+
clearTimeout(timeoutId);
|
|
1617
|
+
resolve();
|
|
1618
|
+
}).catch((error) => {
|
|
1619
|
+
clearTimeout(timeoutId);
|
|
1620
|
+
reject(error);
|
|
1621
|
+
});
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
function closeServer(server) {
|
|
1625
|
+
if (!server.listening) return Promise.resolve();
|
|
1626
|
+
return new Promise((resolve, reject) => {
|
|
1627
|
+
server.close((error) => {
|
|
1628
|
+
if (error) {
|
|
1629
|
+
reject(error);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
resolve();
|
|
1633
|
+
});
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
function isAddressInUseError(error) {
|
|
1637
|
+
if (!(error instanceof Error)) return false;
|
|
1638
|
+
return "code" in error && error.code === "EADDRINUSE";
|
|
1639
|
+
}
|
|
1640
|
+
function isFileNotFoundError$1(error) {
|
|
1641
|
+
if (!(error instanceof Error)) return false;
|
|
1642
|
+
return "code" in error && error.code === "ENOENT";
|
|
1643
|
+
}
|
|
1644
|
+
function createInitialDocument(envFileContent) {
|
|
1645
|
+
if (envFileContent === null) return {
|
|
1646
|
+
entries: [],
|
|
1647
|
+
newline: "\n",
|
|
1648
|
+
endsWithNewline: false
|
|
1649
|
+
};
|
|
1650
|
+
return parseEnvFile(envFileContent);
|
|
1651
|
+
}
|
|
1652
|
+
function escapeHtml(input) {
|
|
1653
|
+
return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
1654
|
+
}
|
|
1655
|
+
function parseClientData(clientDataBase64) {
|
|
1656
|
+
const text = Buffer.from(clientDataBase64, "base64").toString("utf8");
|
|
1657
|
+
const parsed = JSON.parse(text);
|
|
1658
|
+
if (!parsed || typeof parsed.type !== "string" || typeof parsed.challenge !== "string" || typeof parsed.origin !== "string") throw new SessionApiError(400, "invalid_client_data", "Invalid clientDataJSON payload.");
|
|
1659
|
+
return parsed;
|
|
1660
|
+
}
|
|
1661
|
+
function validateRegisterResponsePayload(payload) {
|
|
1662
|
+
if (!payload || typeof payload !== "object") throw new SessionApiError(400, "invalid_registration_payload", "Invalid registration payload.");
|
|
1663
|
+
if (!payload.id || !payload.publicKeySpki || !payload.clientDataJSON || !payload.attestationObject || !payload.prfOutput) throw new SessionApiError(400, "invalid_registration_payload", "id, publicKeySpki, clientDataJSON, attestationObject, and prfOutput are required.");
|
|
1664
|
+
}
|
|
1665
|
+
function validateUnlockResponsePayload(payload) {
|
|
1666
|
+
if (!payload || typeof payload !== "object") throw new SessionApiError(400, "invalid_unlock_payload", "Invalid unlock payload.");
|
|
1667
|
+
if (!payload.id || !payload.clientDataJSON || !payload.authenticatorData || !payload.signature || !payload.prfOutput) throw new SessionApiError(400, "invalid_unlock_payload", "id, clientDataJSON, authenticatorData, signature, and prfOutput are required.");
|
|
1668
|
+
}
|
|
1669
|
+
function verifyAuthenticatorData(authenticatorData, rpId) {
|
|
1670
|
+
if (authenticatorData.length < 37) throw new SessionApiError(400, "invalid_authenticator_data", "Authenticator data is too short.");
|
|
1671
|
+
const expectedRpIdHash = createHash("sha256").update(rpId, "utf8").digest();
|
|
1672
|
+
if (!timingSafeEqual(authenticatorData.subarray(0, 32), expectedRpIdHash)) throw new SessionApiError(400, "invalid_rp_id_hash", "Authenticator RP ID hash mismatch.");
|
|
1673
|
+
if (!((authenticatorData[32] & 4) !== 0)) throw new SessionApiError(400, "user_verification_required", "User verification flag not present.");
|
|
1674
|
+
return authenticatorData.readUInt32BE(33);
|
|
1675
|
+
}
|
|
1676
|
+
function verifyWebAuthnAssertionSignature(publicKeySpkiBase64, authenticatorData, clientDataJSONBase64, signatureBase64) {
|
|
1677
|
+
const publicKey = createPublicKey({
|
|
1678
|
+
key: decodeBase64(publicKeySpkiBase64),
|
|
1679
|
+
format: "der",
|
|
1680
|
+
type: "spki"
|
|
1681
|
+
});
|
|
1682
|
+
const clientDataHash = createHash("sha256").update(decodeBase64(clientDataJSONBase64)).digest();
|
|
1683
|
+
return verify("sha256", Buffer.concat([authenticatorData, clientDataHash]), publicKey, decodeBase64(signatureBase64));
|
|
1684
|
+
}
|
|
1685
|
+
function decodeBase64(value) {
|
|
1686
|
+
return Buffer.from(value, "base64");
|
|
1687
|
+
}
|
|
1688
|
+
function toBase64Url(buffer) {
|
|
1689
|
+
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
1690
|
+
}
|
|
1691
|
+
function wipeMasterKey$1(masterKey) {
|
|
1692
|
+
if (!masterKey) return;
|
|
1693
|
+
masterKey.fill(0);
|
|
1694
|
+
}
|
|
1695
|
+
function completeSessionFromWindowClose(context) {
|
|
1696
|
+
if (context.sessionCompleted) return;
|
|
1697
|
+
if (context.mode === "run" && context.runState && !context.runState.decided) {
|
|
1698
|
+
context.runState.decided = true;
|
|
1699
|
+
context.onRunDecision?.(false);
|
|
1700
|
+
}
|
|
1701
|
+
context.onSessionComplete();
|
|
1702
|
+
}
|
|
1703
|
+
async function main() {
|
|
1704
|
+
try {
|
|
1705
|
+
const args = parseCliArgs(process.argv.slice(2));
|
|
1706
|
+
if (args.type === "view") {
|
|
1707
|
+
await handleViewCommand(args.envFilePathArg, args.verbose);
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
if (args.type === "import") {
|
|
1711
|
+
await handleImportCommand(args.envFilePathArg, args.verbose);
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
if (args.type === "run") {
|
|
1715
|
+
const exitCode = await handleRunCommand(args.envFilePathArg, args.command);
|
|
1716
|
+
process.exitCode = exitCode;
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
if (args.type === "cleanup") {
|
|
1720
|
+
await handleCleanupCommand();
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
await handleConfigPortCommand(args.portArg);
|
|
1724
|
+
} catch (error) {
|
|
1725
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1726
|
+
console.error(message);
|
|
1727
|
+
printUsage();
|
|
1728
|
+
process.exitCode = 1;
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
function extractVerboseFlag(args) {
|
|
1732
|
+
const filteredArgs = [];
|
|
1733
|
+
let verbose = false;
|
|
1734
|
+
for (const arg of args) {
|
|
1735
|
+
if (arg === "--verbose") {
|
|
1736
|
+
verbose = true;
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
filteredArgs.push(arg);
|
|
1740
|
+
}
|
|
1741
|
+
return {
|
|
1742
|
+
filteredArgs,
|
|
1743
|
+
verbose
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
function parseCliArgs(argv) {
|
|
1747
|
+
if (argv.length === 0) throw new Error("Missing command.");
|
|
1748
|
+
const [command, ...rest] = argv;
|
|
1749
|
+
if (command === "view") {
|
|
1750
|
+
const { filteredArgs, verbose } = extractVerboseFlag(rest);
|
|
1751
|
+
if (filteredArgs.length > 1) throw new Error("`rse view` accepts at most one env file path.");
|
|
1752
|
+
return {
|
|
1753
|
+
type: "view",
|
|
1754
|
+
envFilePathArg: filteredArgs[0],
|
|
1755
|
+
verbose
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
if (command === "run") {
|
|
1759
|
+
const separatorIndex = rest.indexOf("--");
|
|
1760
|
+
if (separatorIndex === -1) throw new Error("`rse run` requires `-- <command...>`.");
|
|
1761
|
+
const beforeSeparator = rest.slice(0, separatorIndex);
|
|
1762
|
+
const commandParts = rest.slice(separatorIndex + 1);
|
|
1763
|
+
const { filteredArgs, verbose } = extractVerboseFlag(beforeSeparator);
|
|
1764
|
+
if (filteredArgs.length > 1) throw new Error("`rse run` accepts at most one env file path before `--`.");
|
|
1765
|
+
if (commandParts.length === 0) throw new Error("Missing child command after `--`.");
|
|
1766
|
+
return {
|
|
1767
|
+
type: "run",
|
|
1768
|
+
envFilePathArg: filteredArgs[0],
|
|
1769
|
+
command: commandParts,
|
|
1770
|
+
verbose
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
if (command === "import") {
|
|
1774
|
+
const { filteredArgs, verbose } = extractVerboseFlag(rest);
|
|
1775
|
+
if (filteredArgs.length > 1) throw new Error("`rse import` accepts at most one env file path.");
|
|
1776
|
+
return {
|
|
1777
|
+
type: "import",
|
|
1778
|
+
envFilePathArg: filteredArgs[0],
|
|
1779
|
+
verbose
|
|
1780
|
+
};
|
|
1781
|
+
}
|
|
1782
|
+
if (command === "config") {
|
|
1783
|
+
const { filteredArgs, verbose } = extractVerboseFlag(rest);
|
|
1784
|
+
if (filteredArgs[0] !== "port") throw new Error("Usage: rse config port [port] [--verbose]");
|
|
1785
|
+
if (filteredArgs.length > 2) throw new Error("Usage: rse config port [port] [--verbose]");
|
|
1786
|
+
return {
|
|
1787
|
+
type: "config-port",
|
|
1788
|
+
portArg: filteredArgs[1],
|
|
1789
|
+
verbose
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
if (command === "cleanup") {
|
|
1793
|
+
const { filteredArgs, verbose } = extractVerboseFlag(rest);
|
|
1794
|
+
if (filteredArgs.length > 0) throw new Error("Usage: rse cleanup [--verbose]");
|
|
1795
|
+
return {
|
|
1796
|
+
type: "cleanup",
|
|
1797
|
+
verbose
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
throw new Error(`Unknown command: ${command}`);
|
|
1801
|
+
}
|
|
1802
|
+
async function handleViewCommand(envFilePathArg, verbose) {
|
|
1803
|
+
const envPath = await resolveEnvPath(envFilePathArg);
|
|
1804
|
+
const envContent = await readEnvFileIfExists(envPath);
|
|
1805
|
+
const config = await loadCliConfig();
|
|
1806
|
+
if (!envContent) {
|
|
1807
|
+
console.log(`[rse] env file not found: ${envPath}`);
|
|
1808
|
+
console.log(`[rse] starting local UI session on localhost:${config.uiPort} (mode=view)`);
|
|
1809
|
+
await startUiSession({
|
|
1810
|
+
mode: "view",
|
|
1811
|
+
port: config.uiPort,
|
|
1812
|
+
envFilePath: envPath,
|
|
1813
|
+
envFileContent: null
|
|
1814
|
+
});
|
|
1815
|
+
console.log("[rse] browser connected to local session.");
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
console.log(`[rse] loaded env file: ${envPath}`);
|
|
1819
|
+
if (verbose) {
|
|
1820
|
+
const parsed = parseEnvFile(envContent);
|
|
1821
|
+
const roundTrip = serializeEnvDocument(parsed);
|
|
1822
|
+
console.log(`[rse] lossless parser round-trip check: ${roundTrip === envContent ? "ok" : "mismatch"}`);
|
|
1823
|
+
console.log("[rse] parsed entries (debug):");
|
|
1824
|
+
console.log(formatParsedEntriesForDebug(parsed.entries));
|
|
1825
|
+
}
|
|
1826
|
+
console.log(`[rse] starting local UI session on localhost:${config.uiPort} (mode=view)`);
|
|
1827
|
+
await startUiSession({
|
|
1828
|
+
mode: "view",
|
|
1829
|
+
port: config.uiPort,
|
|
1830
|
+
envFilePath: envPath,
|
|
1831
|
+
envFileContent: envContent
|
|
1832
|
+
});
|
|
1833
|
+
console.log("[rse] browser connected to local session.");
|
|
1834
|
+
}
|
|
1835
|
+
async function handleImportCommand(envFilePathArg, verbose) {
|
|
1836
|
+
const envPath = await resolveEnvPath(envFilePathArg);
|
|
1837
|
+
const envContent = await readEnvFileIfExists(envPath);
|
|
1838
|
+
const config = await loadCliConfig();
|
|
1839
|
+
if (envContent) {
|
|
1840
|
+
console.log(`[rse] loaded env file: ${envPath}`);
|
|
1841
|
+
if (verbose) {
|
|
1842
|
+
const parsed = parseEnvFile(envContent);
|
|
1843
|
+
const roundTrip = serializeEnvDocument(parsed);
|
|
1844
|
+
console.log(`[rse] lossless parser round-trip check: ${roundTrip === envContent ? "ok" : "mismatch"}`);
|
|
1845
|
+
console.log("[rse] parsed entries (debug):");
|
|
1846
|
+
console.log(formatParsedEntriesForDebug(parsed.entries));
|
|
1847
|
+
}
|
|
1848
|
+
} else console.log(`[rse] env file not found: ${envPath}`);
|
|
1849
|
+
console.log(`[rse] starting local UI session on localhost:${config.uiPort} (mode=import)`);
|
|
1850
|
+
await startUiSession({
|
|
1851
|
+
mode: "import",
|
|
1852
|
+
port: config.uiPort,
|
|
1853
|
+
envFilePath: envPath,
|
|
1854
|
+
envFileContent: envContent
|
|
1855
|
+
});
|
|
1856
|
+
console.log("[rse] browser connected to local session.");
|
|
1857
|
+
}
|
|
1858
|
+
async function handleRunCommand(envFilePathArg, command) {
|
|
1859
|
+
const envPath = await resolveEnvPath(envFilePathArg);
|
|
1860
|
+
const envContent = await readEnvFileIfExists(envPath);
|
|
1861
|
+
const config = await loadCliConfig();
|
|
1862
|
+
if (!envContent) {
|
|
1863
|
+
console.log(`[rse] env file not found: ${envPath}; running command unchanged.`);
|
|
1864
|
+
return spawnChild(command);
|
|
1865
|
+
}
|
|
1866
|
+
const parsed = parseEnvFile(envContent);
|
|
1867
|
+
if (!hasEncryptedEntries(parsed)) return spawnChild(command, buildChildEnv(parsed, null));
|
|
1868
|
+
console.log(`[rse] encrypted values detected in ${envPath}; approval is required.`);
|
|
1869
|
+
console.log(`[rse] starting local UI session on localhost:${config.uiPort} (mode=run)`);
|
|
1870
|
+
const runSession = await startUiSession({
|
|
1871
|
+
mode: "run",
|
|
1872
|
+
port: config.uiPort,
|
|
1873
|
+
commandDisplay: command.join(" "),
|
|
1874
|
+
envFilePath: envPath,
|
|
1875
|
+
encryptedEntryCount: countEncryptedEntries(parsed)
|
|
1876
|
+
});
|
|
1877
|
+
if (runSession.mode !== "run") throw new Error("Invalid run session result.");
|
|
1878
|
+
if (!runSession.approved) {
|
|
1879
|
+
console.error("[rse] run was denied.");
|
|
1880
|
+
return 1;
|
|
1881
|
+
}
|
|
1882
|
+
if (!runSession.unlockedMasterKey) {
|
|
1883
|
+
console.error("[rse] run approval completed without unlocked key.");
|
|
1884
|
+
return 1;
|
|
1885
|
+
}
|
|
1886
|
+
const unlockedMasterKey = runSession.unlockedMasterKey;
|
|
1887
|
+
try {
|
|
1888
|
+
return await spawnChild(command, buildChildEnv(parsed, unlockedMasterKey));
|
|
1889
|
+
} finally {
|
|
1890
|
+
wipeMasterKey(unlockedMasterKey);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
async function handleConfigPortCommand(portArg) {
|
|
1894
|
+
if (!portArg) {
|
|
1895
|
+
const config = await loadCliConfig();
|
|
1896
|
+
console.log(`[rse] configured UI port: ${config.uiPort}`);
|
|
1897
|
+
console.log(`[rse] config file: ${getConfigFilePath()}`);
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
const updated = await setUiPort(parseAndValidatePort(portArg));
|
|
1901
|
+
console.log(`[rse] updated UI port: ${updated.uiPort}`);
|
|
1902
|
+
console.log(`[rse] config file: ${getConfigFilePath()}`);
|
|
1903
|
+
}
|
|
1904
|
+
async function handleCleanupCommand() {
|
|
1905
|
+
const clearedDir = await clearCliState();
|
|
1906
|
+
console.log(`[rse] cleared local state: ${clearedDir}`);
|
|
1907
|
+
console.log("[rse] registration, wrapped master key, counters, and cli config were removed.");
|
|
1908
|
+
}
|
|
1909
|
+
async function resolveEnvPath(envFilePathArg) {
|
|
1910
|
+
if (!envFilePathArg) return path.resolve(process.cwd(), ".env");
|
|
1911
|
+
const candidatePath = path.resolve(process.cwd(), envFilePathArg);
|
|
1912
|
+
try {
|
|
1913
|
+
if ((await stat(candidatePath)).isDirectory()) return path.join(candidatePath, ".env");
|
|
1914
|
+
return candidatePath;
|
|
1915
|
+
} catch (error) {
|
|
1916
|
+
if (isFileNotFoundError(error)) return candidatePath;
|
|
1917
|
+
throw error;
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
async function readEnvFileIfExists(filePath) {
|
|
1921
|
+
try {
|
|
1922
|
+
return await readFile(filePath, "utf8");
|
|
1923
|
+
} catch (error) {
|
|
1924
|
+
if (isFileNotFoundError(error)) return null;
|
|
1925
|
+
throw error;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
function isFileNotFoundError(error) {
|
|
1929
|
+
if (!(error instanceof Error)) return false;
|
|
1930
|
+
return "code" in error && error.code === "ENOENT";
|
|
1931
|
+
}
|
|
1932
|
+
function hasEncryptedEntries(document) {
|
|
1933
|
+
return document.entries.some((entry) => entry.type === "pair" && entry.rawValue.startsWith("enc:v1:"));
|
|
1934
|
+
}
|
|
1935
|
+
function countEncryptedEntries(document) {
|
|
1936
|
+
return document.entries.filter((entry) => entry.type === "pair" && entry.rawValue.startsWith("enc:v1:")).length;
|
|
1937
|
+
}
|
|
1938
|
+
function buildChildEnv(document, masterKey) {
|
|
1939
|
+
const envFromFile = {};
|
|
1940
|
+
for (const entry of document.entries) {
|
|
1941
|
+
if (entry.type !== "pair") continue;
|
|
1942
|
+
if (entry.rawValue.startsWith("enc:v1:")) {
|
|
1943
|
+
if (!masterKey) throw new Error(`Missing unlocked key for encrypted env key: ${entry.key}`);
|
|
1944
|
+
envFromFile[entry.key] = decryptEnvValue(entry.rawValue, entry.key, masterKey);
|
|
1945
|
+
continue;
|
|
1946
|
+
}
|
|
1947
|
+
envFromFile[entry.key] = decodeEnvValue(entry.rawValue);
|
|
1948
|
+
}
|
|
1949
|
+
return {
|
|
1950
|
+
...process.env,
|
|
1951
|
+
...envFromFile
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
function spawnChild(command, env = process.env) {
|
|
1955
|
+
return new Promise((resolve, reject) => {
|
|
1956
|
+
const child = spawn(command[0], command.slice(1), {
|
|
1957
|
+
stdio: "inherit",
|
|
1958
|
+
env
|
|
1959
|
+
});
|
|
1960
|
+
child.on("error", reject);
|
|
1961
|
+
child.on("exit", (code, signal) => {
|
|
1962
|
+
if (signal) {
|
|
1963
|
+
console.error(`[rse] child process terminated by signal ${signal}`);
|
|
1964
|
+
resolve(1);
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
resolve(code ?? 1);
|
|
1968
|
+
});
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
function wipeMasterKey(masterKey) {
|
|
1972
|
+
if (!masterKey) return;
|
|
1973
|
+
masterKey.fill(0);
|
|
1974
|
+
}
|
|
1975
|
+
function printUsage() {
|
|
1976
|
+
console.error("Usage:");
|
|
1977
|
+
console.error(" rse view [envFilePath] [--verbose]");
|
|
1978
|
+
console.error(" rse import [envFilePath] [--verbose]");
|
|
1979
|
+
console.error(" rse run [envFilePath] [--verbose] -- <command...>");
|
|
1980
|
+
console.error(" rse config port [port] [--verbose]");
|
|
1981
|
+
console.error(" rse cleanup [--verbose]");
|
|
1982
|
+
}
|
|
1983
|
+
main();
|
|
1984
|
+
export {};
|