@bitkyc08/opencodex 2.78.0 → 2.80.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/gui/dist/assets/App-CSJvmjNs.css +1 -0
- package/gui/dist/assets/App-MbDVfUeC.js +54 -0
- package/gui/dist/assets/{Tray-DiJpSub0.js → Tray-CzkxK82A.js} +1 -1
- package/gui/dist/assets/{index-DmQB8D_R.css → index-Brk6F3fn.css} +1 -1
- package/gui/dist/assets/index-UJt6npXk.js +94 -0
- package/gui/dist/assets/{quota-summary-B_a96nXj.js → quota-summary-DU3sD6Z-.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +3 -2
- package/src/adapters/cursor/live-transport.ts +5 -3
- package/src/adapters/cursor/native-exec-fs.ts +12 -9
- package/src/adapters/cursor/native-exec.ts +13 -5
- package/src/adapters/cursor/protobuf-request.ts +13 -7
- package/src/adapters/cursor/request-builder.ts +23 -10
- package/src/adapters/cursor/tool-definitions.ts +9 -6
- package/src/adapters/cursor/tool-guidance.ts +29 -8
- package/src/adapters/cursor/tool-naming.ts +40 -7
- package/src/adapters/cursor/tool-wording.ts +46 -0
- package/src/adapters/cursor.ts +3 -0
- package/src/adapters/devin.ts +37 -2
- package/src/adapters/openai-chat/messages.ts +3 -1
- package/src/claude/desktop-picker.ts +11 -9
- package/src/claude/inbound-content-options.ts +29 -2
- package/src/claude/inbound.ts +9 -0
- package/src/claude/intercept/local-ca-files.ts +265 -0
- package/src/claude/intercept/local-ca-windows.ts +73 -0
- package/src/claude/intercept/local-ca.ts +12 -24
- package/src/claude/intercept/picker-ca-persistence.ts +106 -0
- package/src/claude/intercept/picker-ca-startup.ts +16 -0
- package/src/claude/intercept/picker-ca-store.ts +70 -0
- package/src/claude/intercept/picker-ca.ts +43 -9
- package/src/claude/intercept/picker-runtime.ts +14 -4
- package/src/claude/intercept/runtime.ts +32 -23
- package/src/cli/ensure-readiness.ts +48 -0
- package/src/cli/gui-pair-client.ts +15 -7
- package/src/cli/gui.ts +4 -1
- package/src/cli/hub.ts +1 -0
- package/src/cli/index.ts +10 -11
- package/src/cli/status.ts +2 -2
- package/src/cli/update-restart-child.ts +4 -1
- package/src/codex/account-credit-use.ts +8 -2
- package/src/codex/auth-api/account-pause-group.ts +113 -0
- package/src/codex/auth-api/routes.ts +18 -12
- package/src/codex/auth-context.ts +45 -3
- package/src/codex/autostart-health.ts +13 -3
- package/src/codex/journal.ts +36 -5
- package/src/codex/pool-credit-policy.ts +67 -0
- package/src/codex/shim-overlay.ts +9 -1
- package/src/combos/jev.ts +3 -1
- package/src/generated/compatibility-version.json +135 -63
- package/src/lib/gui-pair-capability.ts +1 -1
- package/src/lib/gui-pair-intent.ts +170 -0
- package/src/lib/windows-owner-acl.ts +143 -0
- package/src/providers/openai-sidecar-credit.ts +26 -0
- package/src/providers/openai-sidecar.ts +35 -0
- package/src/server/claude-messages.ts +1 -0
- package/src/server/gui-pair-delivery.ts +23 -0
- package/src/server/index/serve-options.ts +5 -8
- package/src/server/index.ts +12 -14
- package/src/server/live-native-calls.ts +94 -0
- package/src/server/live.ts +28 -5
- package/src/server/local-management-read-client.ts +15 -11
- package/src/server/management/oauth-account-routes.ts +15 -0
- package/src/server/management/route-registry.ts +1 -0
- package/src/server/management/shared.ts +9 -3
- package/src/server/messages-native-scope.ts +60 -0
- package/src/server/messages-native.ts +9 -0
- package/src/server/responses/adapter-dispatch.ts +31 -2
- package/src/server/responses/combo-requested-effort.ts +14 -0
- package/src/server/responses/compact.ts +17 -2
- package/src/server/responses/core-auth.ts +18 -1
- package/src/server/responses/core-codex-account.ts +18 -8
- package/src/server/responses/core-combo.ts +5 -7
- package/src/server/responses/non-replayable-error.ts +48 -0
- package/src/server/responses/passthrough-delivery.ts +5 -1
- package/src/server/responses/passthrough-dispatch.ts +21 -19
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/sidecar-execution.ts +1 -0
- package/src/server/search.ts +4 -0
- package/src/server/startup-health-cache.ts +2 -6
- package/src/service/desktop-startup.ts +100 -3
- package/src/vision/describe.ts +17 -10
- package/src/vision/index.ts +1 -0
- package/src/web-search/executor.ts +17 -10
- package/src/web-search/format-result.ts +304 -20
- package/src/web-search/header-deadline.ts +67 -0
- package/src/web-search/loop.ts +7 -6
- package/src/web-search/passthrough-bridge.ts +1 -0
- package/src/web-search/run-turn-loop.ts +1 -1
- package/gui/dist/assets/App-BtfG5id-.js +0 -54
- package/gui/dist/assets/App-lCIslZ1L.css +0 -1
- package/gui/dist/assets/index-Btc3Ogw4.js +0 -94
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeFileSync, type BigIntStats } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { withClientLifecycleSync } from "../../client/lifecycle-lock";
|
|
5
|
+
import { forgetEphemeralSecretPath, hardenSecretDir, hardenSecretPath, windowsSecretAclApplies } from "../../lib/windows-secret-acl";
|
|
6
|
+
import { assertLocalCaWindowsAcl } from "./local-ca-windows";
|
|
7
|
+
|
|
8
|
+
const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
9
|
+
const NONBLOCK = constants.O_NONBLOCK ?? 0;
|
|
10
|
+
const MAX_PEM_BYTES = 64 * 1024;
|
|
11
|
+
const KEY = "ca.key";
|
|
12
|
+
const CERT = "ca.pem";
|
|
13
|
+
type FileMode = 0o600 | 0o644;
|
|
14
|
+
type AclLevel = "owner" | "inherited" | "private";
|
|
15
|
+
type AclCheck = ((path: string, stat: BigIntStats, level?: AclLevel) => void) & { forget(stat: BigIntStats): void };
|
|
16
|
+
type FileHooks = { beforeRead?: (path: string) => void; beforeWrite?: (path: string) => void; beforePublish?: (path: string) => void };
|
|
17
|
+
let hooks: FileHooks = {};
|
|
18
|
+
/** Boundary fault injection for substitution and zero-secret-write assertions. */
|
|
19
|
+
export function setLocalCaFileHooksForTests(next: FileHooks | null): void { hooks = next ?? {}; }
|
|
20
|
+
|
|
21
|
+
function unsafe(): Error {
|
|
22
|
+
return Object.assign(new Error("Local CA requires safe owner-controlled files and directory"), { code: "local_ca_path_unsafe" });
|
|
23
|
+
}
|
|
24
|
+
function missing(error: unknown): boolean { return (error as NodeJS.ErrnoException)?.code === "ENOENT"; }
|
|
25
|
+
function owned(stat: BigIntStats, directory = false, mode: FileMode = 0o600): void {
|
|
26
|
+
if (stat.isSymbolicLink() || !(directory ? stat.isDirectory() : stat.isFile() && stat.nlink === 1n)) throw unsafe();
|
|
27
|
+
if (!windowsSecretAclApplies()) {
|
|
28
|
+
if (typeof process.geteuid !== "function" || stat.uid !== BigInt(process.geteuid())) throw unsafe();
|
|
29
|
+
const forbidden = directory || mode === 0o600 ? 0o077 : 0o022;
|
|
30
|
+
if ((Number(stat.mode) & forbidden) !== 0) throw unsafe();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function same(path: string, expected: BigIntStats, directory = false, mode: FileMode = 0o600): BigIntStats {
|
|
34
|
+
const stat = lstatSync(path, { bigint: true });
|
|
35
|
+
owned(stat, directory, mode);
|
|
36
|
+
if (stat.dev !== expected.dev || stat.ino !== expected.ino || stat.ino === 0n) throw unsafe();
|
|
37
|
+
return stat;
|
|
38
|
+
}
|
|
39
|
+
function inspect(path: string, mode: FileMode, checkAcl: AclCheck, sqliteSidecar = false): BigIntStats | null {
|
|
40
|
+
let stat: BigIntStats;
|
|
41
|
+
try { stat = lstatSync(path, { bigint: true }); } catch (error) { if (missing(error)) return null; throw error; }
|
|
42
|
+
owned(stat, false, mode);
|
|
43
|
+
if (windowsSecretAclApplies()) {
|
|
44
|
+
if (sqliteSidecar) checkAcl(path, stat, "inherited");
|
|
45
|
+
else {
|
|
46
|
+
try { checkAcl(path, stat); }
|
|
47
|
+
catch {
|
|
48
|
+
// Legacy files may inherit only trusted grants; verify before narrowing.
|
|
49
|
+
checkAcl(path, stat, "inherited");
|
|
50
|
+
same(path, stat, false, mode);
|
|
51
|
+
hardenSecretPath(path, { required: true });
|
|
52
|
+
same(path, stat, false, mode);
|
|
53
|
+
checkAcl(path, stat);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
same(path, stat, false, mode);
|
|
58
|
+
return stat;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function protectNew(path: string, fd: number, mode: FileMode, checkAcl: AclCheck): void {
|
|
62
|
+
const created = fstatSync(fd, { bigint: true });
|
|
63
|
+
owned(created, false, mode);
|
|
64
|
+
if (windowsSecretAclApplies()) {
|
|
65
|
+
// Only O_CREAT|O_EXCL entries reach here. Narrow the empty file we created,
|
|
66
|
+
// then strictly verify before writing; an Administrators default owner still fails closed.
|
|
67
|
+
same(path, created, false, mode);
|
|
68
|
+
hardenSecretPath(path, { required: true });
|
|
69
|
+
checkAcl(path, created);
|
|
70
|
+
} else fchmodSync(fd, mode);
|
|
71
|
+
same(path, created, false, mode);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readPinned(path: string, expected: BigIntStats, mode: FileMode, assertDirectory: () => void, checkAcl: AclCheck): string {
|
|
75
|
+
hooks.beforeRead?.(path);
|
|
76
|
+
assertDirectory();
|
|
77
|
+
same(path, expected, false, mode);
|
|
78
|
+
const fd = openSync(path, constants.O_RDONLY | NOFOLLOW | NONBLOCK);
|
|
79
|
+
try {
|
|
80
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
81
|
+
owned(opened, false, mode);
|
|
82
|
+
if (opened.dev !== expected.dev || opened.ino !== expected.ino || opened.size > BigInt(MAX_PEM_BYTES)) throw unsafe();
|
|
83
|
+
checkAcl(path, opened);
|
|
84
|
+
same(path, opened, false, mode);
|
|
85
|
+
const bytes = Buffer.alloc(MAX_PEM_BYTES + 1);
|
|
86
|
+
let length = 0;
|
|
87
|
+
while (length < bytes.length) {
|
|
88
|
+
const count = readSync(fd, bytes, length, bytes.length - length, length);
|
|
89
|
+
if (!count) break;
|
|
90
|
+
length += count;
|
|
91
|
+
}
|
|
92
|
+
const after = fstatSync(fd, { bigint: true });
|
|
93
|
+
owned(after, false, mode);
|
|
94
|
+
if (length > MAX_PEM_BYTES || BigInt(length) !== after.size || after.mtimeNs !== opened.mtimeNs || after.ctimeNs !== opened.ctimeNs) throw unsafe();
|
|
95
|
+
same(path, opened, false, mode);
|
|
96
|
+
assertDirectory();
|
|
97
|
+
return bytes.subarray(0, length).toString("utf8");
|
|
98
|
+
} finally { closeSync(fd); }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface LocalCaFiles {
|
|
102
|
+
readPair(): { certPem: string; keyPem: string } | null;
|
|
103
|
+
writePair(pair: { certPem: string; keyPem: string }): void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function stage(path: string, contents: string, mode: FileMode, assertDirectory: () => void, checkAcl: AclCheck): { publish(): void; dispose(): void } {
|
|
107
|
+
assertDirectory();
|
|
108
|
+
const previous = inspect(path, mode, checkAcl);
|
|
109
|
+
const temp = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(16).toString("hex")}.tmp`);
|
|
110
|
+
const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW | NONBLOCK, mode);
|
|
111
|
+
let created: BigIntStats;
|
|
112
|
+
try { created = fstatSync(fd, { bigint: true }); }
|
|
113
|
+
catch (error) { closeSync(fd); throw error; }
|
|
114
|
+
let closed = false;
|
|
115
|
+
let removed = false;
|
|
116
|
+
const dispose = (): void => {
|
|
117
|
+
if (!closed) { closed = true; closeSync(fd); }
|
|
118
|
+
if (removed) return;
|
|
119
|
+
try { same(temp, created, false, mode); unlinkSync(temp); removed = true; }
|
|
120
|
+
catch (error) { if (missing(error)) removed = true; else throw error; }
|
|
121
|
+
if (removed) { checkAcl.forget(created); forgetEphemeralSecretPath(temp); }
|
|
122
|
+
};
|
|
123
|
+
try {
|
|
124
|
+
protectNew(temp, fd, mode, checkAcl);
|
|
125
|
+
assertDirectory();
|
|
126
|
+
hooks.beforeWrite?.(temp);
|
|
127
|
+
same(temp, created, false, mode);
|
|
128
|
+
assertDirectory();
|
|
129
|
+
checkAcl(temp, created);
|
|
130
|
+
writeFileSync(fd, contents, { encoding: "utf8" });
|
|
131
|
+
same(temp, created, false, mode);
|
|
132
|
+
return {
|
|
133
|
+
publish() {
|
|
134
|
+
hooks.beforePublish?.(path);
|
|
135
|
+
assertDirectory();
|
|
136
|
+
same(temp, created, false, mode);
|
|
137
|
+
checkAcl(temp, created);
|
|
138
|
+
const current = inspect(path, mode, checkAcl);
|
|
139
|
+
if (previous ? !current || previous.dev !== current.dev || previous.ino !== current.ino : current !== null) throw unsafe();
|
|
140
|
+
renameSync(temp, path);
|
|
141
|
+
if (current) checkAcl.forget(current);
|
|
142
|
+
removed = true;
|
|
143
|
+
forgetEphemeralSecretPath(temp);
|
|
144
|
+
same(path, created, false, mode);
|
|
145
|
+
assertDirectory();
|
|
146
|
+
},
|
|
147
|
+
dispose,
|
|
148
|
+
};
|
|
149
|
+
} catch (error) {
|
|
150
|
+
try { dispose(); } catch { /* preserve safety failure; never remove a substituted path */ }
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Guard every CA entry before SQLite or PEM access. The directory and lock stay pinned. */
|
|
156
|
+
export function withLocalCaPublication<T>(dir: string, lockName: string, work: (files: LocalCaFiles) => T): T {
|
|
157
|
+
if (basename(lockName) !== lockName || [KEY, CERT, ".", ".."].includes(lockName)) throw unsafe();
|
|
158
|
+
mkdirSync(dirname(dir), { recursive: true, mode: 0o700 });
|
|
159
|
+
let created = false;
|
|
160
|
+
try { mkdirSync(dir, { mode: 0o700 }); created = true; }
|
|
161
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
162
|
+
// Publication-local only: a private DACL can be changed only by the owner,
|
|
163
|
+
// SYSTEM or Administrators. Every memo hit still checks lstat identity via same().
|
|
164
|
+
// bigint birthtimeNs adds discrimination for recycled SQLite sidecar file IDs; it is not a
|
|
165
|
+
// uniqueness guarantee (NTFS tunneling can restore creation times). Safety rests on the
|
|
166
|
+
// verified-private directory DACL. Creation time survives same-volume rename, so staged
|
|
167
|
+
// entries keep their memo.
|
|
168
|
+
const verified = new Map<string, AclLevel>();
|
|
169
|
+
const strength = { owner: 0, inherited: 1, private: 2 };
|
|
170
|
+
const checkAcl: AclCheck = Object.assign((path: string, stat: BigIntStats, level: AclLevel = "private") => {
|
|
171
|
+
if (!windowsSecretAclApplies()) return;
|
|
172
|
+
const identity = same(path, stat, stat.isDirectory());
|
|
173
|
+
const key = `${identity.dev}:${identity.ino}:${identity.birthtimeNs}`;
|
|
174
|
+
const prior = verified.get(key);
|
|
175
|
+
if (prior !== undefined && strength[prior] >= strength[level]) return;
|
|
176
|
+
assertLocalCaWindowsAcl(path, level);
|
|
177
|
+
same(path, stat, stat.isDirectory());
|
|
178
|
+
verified.set(key, level);
|
|
179
|
+
}, {
|
|
180
|
+
// An identity this publication unlinked or replaced is retired; a recycled id must be re-inspected.
|
|
181
|
+
forget(stat: BigIntStats): void { verified.delete(`${stat.dev}:${stat.ino}:${stat.birthtimeNs}`); },
|
|
182
|
+
});
|
|
183
|
+
const directory = lstatSync(dir, { bigint: true });
|
|
184
|
+
owned(directory, true);
|
|
185
|
+
const dirFd = windowsSecretAclApplies() ? null : openSync(dir, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | NOFOLLOW);
|
|
186
|
+
let lockFd: number | undefined;
|
|
187
|
+
try {
|
|
188
|
+
if (dirFd !== null) {
|
|
189
|
+
const opened = fstatSync(dirFd, { bigint: true });
|
|
190
|
+
if (opened.dev !== directory.dev || opened.ino !== directory.ino) throw unsafe();
|
|
191
|
+
owned(opened, true);
|
|
192
|
+
} else {
|
|
193
|
+
let needsHardening = created;
|
|
194
|
+
// A successful mkdir created this directory; strict verification after narrowing
|
|
195
|
+
// still refuses an Administrators default owner before any CA access.
|
|
196
|
+
if (!created) {
|
|
197
|
+
try { checkAcl(dir, directory); }
|
|
198
|
+
catch { checkAcl(dir, directory, "inherited"); needsHardening = true; }
|
|
199
|
+
}
|
|
200
|
+
if (needsHardening) {
|
|
201
|
+
same(dir, directory, true);
|
|
202
|
+
hardenSecretDir(dir, { required: true });
|
|
203
|
+
same(dir, directory, true);
|
|
204
|
+
checkAcl(dir, directory);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const assertDirectory = (): void => { same(dir, directory, true); };
|
|
208
|
+
assertDirectory();
|
|
209
|
+
const lockPath = join(dir, lockName);
|
|
210
|
+
const assertEntries = (): void => {
|
|
211
|
+
assertDirectory();
|
|
212
|
+
inspect(join(dir, KEY), 0o600, checkAcl);
|
|
213
|
+
inspect(join(dir, CERT), 0o644, checkAcl);
|
|
214
|
+
inspect(lockPath, 0o600, checkAcl);
|
|
215
|
+
// SQLite creates sidecars itself, inheriting only the protected directory's
|
|
216
|
+
// current-user ACE. Inspect every grant and owner; an inherited DACL is valid here.
|
|
217
|
+
for (const suffix of ["-journal", "-wal", "-shm"]) inspect(`${lockPath}${suffix}`, 0o600, checkAcl, true);
|
|
218
|
+
assertDirectory();
|
|
219
|
+
};
|
|
220
|
+
assertEntries();
|
|
221
|
+
try {
|
|
222
|
+
lockFd = openSync(lockPath, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | NOFOLLOW | NONBLOCK, 0o600);
|
|
223
|
+
protectNew(lockPath, lockFd, 0o600, checkAcl);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
226
|
+
const existing = inspect(lockPath, 0o600, checkAcl);
|
|
227
|
+
if (!existing) throw unsafe();
|
|
228
|
+
lockFd = openSync(lockPath, constants.O_RDONLY | NOFOLLOW | NONBLOCK);
|
|
229
|
+
const opened = fstatSync(lockFd, { bigint: true });
|
|
230
|
+
owned(opened);
|
|
231
|
+
if (opened.dev !== existing.dev || opened.ino !== existing.ino) throw unsafe();
|
|
232
|
+
}
|
|
233
|
+
const lock = fstatSync(lockFd, { bigint: true });
|
|
234
|
+
assertEntries();
|
|
235
|
+
same(lockPath, lock);
|
|
236
|
+
return withClientLifecycleSync(() => {
|
|
237
|
+
same(lockPath, lock);
|
|
238
|
+
assertEntries();
|
|
239
|
+
const files: LocalCaFiles = {
|
|
240
|
+
readPair() {
|
|
241
|
+
assertEntries();
|
|
242
|
+
const cert = inspect(join(dir, CERT), 0o644, checkAcl);
|
|
243
|
+
const key = inspect(join(dir, KEY), 0o600, checkAcl);
|
|
244
|
+
if (!cert || !key) return null;
|
|
245
|
+
return { certPem: readPinned(join(dir, CERT), cert, 0o644, assertDirectory, checkAcl), keyPem: readPinned(join(dir, KEY), key, 0o600, assertDirectory, checkAcl) };
|
|
246
|
+
},
|
|
247
|
+
writePair(pair) {
|
|
248
|
+
assertEntries();
|
|
249
|
+
const key = stage(join(dir, KEY), pair.keyPem, 0o600, assertDirectory, checkAcl);
|
|
250
|
+
let cert: ReturnType<typeof stage> | undefined;
|
|
251
|
+
try {
|
|
252
|
+
cert = stage(join(dir, CERT), pair.certPem, 0o644, assertDirectory, checkAcl);
|
|
253
|
+
assertEntries();
|
|
254
|
+
key.publish();
|
|
255
|
+
cert.publish();
|
|
256
|
+
} finally { try { key.dispose(); } finally { cert?.dispose(); } }
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
const value = work(files);
|
|
260
|
+
same(lockPath, lock);
|
|
261
|
+
assertEntries();
|
|
262
|
+
return value;
|
|
263
|
+
}, { lockPath });
|
|
264
|
+
} finally { try { if (lockFd !== undefined) closeSync(lockFd); } finally { if (dirFd !== null) closeSync(dirFd); } }
|
|
265
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Strict local-CA policy layered on the shared Windows ACL hardener. */
|
|
2
|
+
import { resolveTrustedWindowsPowerShellExe } from "../../lib/windows-elevation";
|
|
3
|
+
import { resolveCurrentWindowsPrincipal, WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS } from "../../lib/windows-user-principal";
|
|
4
|
+
import type { IcaclsResult } from "../../lib/windows-secret-acl";
|
|
5
|
+
|
|
6
|
+
type AclRunner = (path: string, timeoutMs: number) => IcaclsResult;
|
|
7
|
+
const MAX_ACL_BYTES = 64 * 1024;
|
|
8
|
+
const SID = /^S-1-(?:\d+-)+\d+$/i;
|
|
9
|
+
|
|
10
|
+
function nativeAclRunner(path: string, timeoutMs: number): IcaclsResult {
|
|
11
|
+
// Encode the path as data, not PowerShell syntax. SID-form rules avoid localized account names.
|
|
12
|
+
// .NET only: Get-Acl/ConvertTo-Json/ForEach-Object are module-autoloaded cmdlets, and
|
|
13
|
+
// autoload resolves them through the per-profile module analysis cache. With a fresh or
|
|
14
|
+
// redirected LOCALAPPDATA that rebuild scans every PSModulePath module and can exceed the
|
|
15
|
+
// inspection budget (hosted Windows runners: 30 s timeouts during `ocx start`); it also lets
|
|
16
|
+
// a module earlier on PSModulePath shadow the cmdlet. SIDs, ints and booleans need no escaping;
|
|
17
|
+
// the JSON quote is [char]34 so the -Command argument carries no double quotes to re-parse.
|
|
18
|
+
const encodedPath = Buffer.from(path, "utf8").toString("base64");
|
|
19
|
+
const script = [
|
|
20
|
+
"$ErrorActionPreference='Stop'",
|
|
21
|
+
`$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPath}'))`,
|
|
22
|
+
"$s=[System.Security.Principal.SecurityIdentifier]",
|
|
23
|
+
"$q=[char]34",
|
|
24
|
+
"$c=[System.Security.AccessControl.AccessControlSections]'Owner,Access'",
|
|
25
|
+
"if([System.IO.Directory]::Exists($p)){$a=[System.Security.AccessControl.DirectorySecurity]::new($p,$c)}else{$a=[System.Security.AccessControl.FileSecurity]::new($p,$c)}",
|
|
26
|
+
"$r=foreach($x in $a.GetAccessRules($true,$true,$s)){'{'+$q+'sid'+$q+':'+$q+$x.IdentityReference.Value+$q+','+$q+'type'+$q+':'+[int]$x.AccessControlType+','+$q+'rights'+$q+':'+[long]$x.FileSystemRights+'}'}",
|
|
27
|
+
"$o='{'+$q+'owner'+$q+':'+$q+$a.GetOwner($s).Value+$q+','+$q+'protected'+$q+':'+$(if($a.AreAccessRulesProtected){'true'}else{'false'})+','+$q+'rules'+$q+':['+(@($r) -join ',')+']}'",
|
|
28
|
+
"[Console]::Out.Write($o)",
|
|
29
|
+
"[Console]::Out.Flush()",
|
|
30
|
+
].join(";");
|
|
31
|
+
const result = Bun.spawnSync([resolveTrustedWindowsPowerShellExe(), "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], {
|
|
32
|
+
stdin: "ignore", stdout: "pipe", stderr: "ignore", timeout: timeoutMs, windowsHide: true,
|
|
33
|
+
});
|
|
34
|
+
return { success: result.success, exitCode: result.exitCode, timedOut: result.exitedDueToTimeout ?? false, stdout: result.stdout.toString("utf8") };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let aclRunner: AclRunner = nativeAclRunner;
|
|
38
|
+
/** Native-result fault injection; production always uses the trusted bounded runner. */
|
|
39
|
+
export function setLocalCaWindowsAclRunnerForTests(next: AclRunner | null): void {
|
|
40
|
+
aclRunner = next ?? nativeAclRunner;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function assertLocalCaWindowsAcl(path: string, inspection: "owner" | "private" | "inherited" = "private"): void {
|
|
44
|
+
try {
|
|
45
|
+
const deadline = Date.now() + WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS;
|
|
46
|
+
const current = resolveCurrentWindowsPrincipal(deadline - Date.now()).replace(/^\*/, "").toUpperCase();
|
|
47
|
+
if (!SID.test(current)) throw new Error("invalid effective SID");
|
|
48
|
+
const remaining = deadline - Date.now();
|
|
49
|
+
if (remaining <= 0) throw new Error("ACL inspection deadline");
|
|
50
|
+
const result = aclRunner(path, remaining);
|
|
51
|
+
if (!result.success || result.timedOut || result.exitCode !== 0 || Buffer.byteLength(result.stdout) > MAX_ACL_BYTES) throw new Error("ACL inspection failed");
|
|
52
|
+
const acl: unknown = JSON.parse(result.stdout.replace(/^\uFEFF/, ""));
|
|
53
|
+
if (!acl || typeof acl !== "object" || !("owner" in acl) || !("protected" in acl) || !("rules" in acl)
|
|
54
|
+
|| typeof acl.owner !== "string" || acl.owner.toUpperCase() !== current
|
|
55
|
+
|| typeof acl.protected !== "boolean" || !Array.isArray(acl.rules)) throw new Error("unsafe ACL owner or shape");
|
|
56
|
+
// Newly created empty entries can inherit broad grants. Verify the owner
|
|
57
|
+
// before hardening, then require the complete private policy before use.
|
|
58
|
+
if (inspection === "owner") return;
|
|
59
|
+
if (inspection === "private" && !acl.protected) throw new Error("unprotected DACL");
|
|
60
|
+
const allowed = new Set([current, "S-1-5-18", "S-1-5-32-544"]);
|
|
61
|
+
if (acl.rules.length === 0) throw new Error("empty DACL");
|
|
62
|
+
for (const rule of acl.rules) {
|
|
63
|
+
if (!rule || typeof rule !== "object" || typeof rule.sid !== "string" || !SID.test(rule.sid)
|
|
64
|
+
|| ![0, 1].includes(rule.type) || !Number.isSafeInteger(rule.rights) || rule.rights < 0 || rule.rights > 0xffffffff) throw new Error("ambiguous ACL rule");
|
|
65
|
+
// Reject every unexpected nonzero Allow ACE, including inherited and tamper-only grants.
|
|
66
|
+
// Deny ACEs confer no access. SYSTEM and built-in Administrators are the only exceptions.
|
|
67
|
+
if (rule.type === 0 && rule.rights !== 0 && !allowed.has(rule.sid.toUpperCase())) throw new Error("unexpected ACL principal");
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// Never expose path, raw ACL output, account names or native diagnostic text.
|
|
71
|
+
throw Object.assign(new Error("Local CA Windows owner/ACL inspection failed"), { code: "local_ca_path_unsafe" });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createHash, generateKeyPairSync, createPrivateKey, createPublicKey, sign, X509Certificate, type KeyObject } from "node:crypto";
|
|
2
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
2
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
3
|
+
import { withLocalCaPublication, type LocalCaFiles } from "./local-ca-files";
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Local certificate authority for the Claude intercept listener.
|
|
@@ -307,7 +306,6 @@ export function issueLocalInterceptLeaf(ca: LocalInterceptCa, hosts: readonly st
|
|
|
307
306
|
|
|
308
307
|
export const CLAUDE_INTERCEPT_STATE_DIR = "claude-intercept";
|
|
309
308
|
export const CLAUDE_INTERCEPT_CA_CERT_FILE = "ca.pem";
|
|
310
|
-
const CA_KEY_FILE = "ca.key";
|
|
311
309
|
|
|
312
310
|
export function claudeInterceptStateDir(configDir: string): string {
|
|
313
311
|
return join(configDir, CLAUDE_INTERCEPT_STATE_DIR);
|
|
@@ -318,50 +316,40 @@ export function claudeInterceptCaCertPath(configDir: string): string {
|
|
|
318
316
|
return join(claudeInterceptStateDir(configDir), CLAUDE_INTERCEPT_CA_CERT_FILE);
|
|
319
317
|
}
|
|
320
318
|
|
|
321
|
-
function
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function loadPersistedCa(dir: string, accept?: (cert: X509Certificate) => boolean): LocalInterceptCa | null {
|
|
329
|
-
const certPath = join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE);
|
|
330
|
-
const keyPath = join(dir, CA_KEY_FILE);
|
|
331
|
-
if (!existsSync(certPath) || !existsSync(keyPath)) return null;
|
|
319
|
+
function loadPersistedCa(files: LocalCaFiles, accept?: (cert: X509Certificate) => boolean): LocalInterceptCa | null {
|
|
320
|
+
// Filesystem safety and access errors must escape; only benign PEM corruption regenerates.
|
|
321
|
+
const pair = files.readPair();
|
|
322
|
+
if (!pair) return null;
|
|
323
|
+
const { certPem, keyPem } = pair;
|
|
332
324
|
try {
|
|
333
|
-
const certPem = readFileSync(certPath, "utf8");
|
|
334
|
-
const keyPem = readFileSync(keyPath, "utf8");
|
|
335
325
|
const privateKey = createPrivateKey(keyPem);
|
|
336
326
|
const publicKey = createPublicKey(keyPem);
|
|
337
327
|
const certificate = new X509Certificate(certPem);
|
|
338
328
|
if (!certificate.ca || !certificate.checkPrivateKey(privateKey) || !certificate.verify(publicKey)
|
|
339
329
|
|| (accept && !accept(certificate))) return null;
|
|
340
330
|
return { certPem, keyPem, publicKey, privateKey };
|
|
341
|
-
} catch { // no-excuse-ok: catch --
|
|
331
|
+
} catch { // no-excuse-ok: catch -- malformed PEM in validated owner-controlled files is regenerated below.
|
|
342
332
|
return null;
|
|
343
333
|
}
|
|
344
334
|
}
|
|
345
335
|
|
|
346
|
-
/** Persist an authority under its own lease, replacing
|
|
336
|
+
/** Persist an authority under its own lease, replacing corrupt or rejected safe pairs. */
|
|
347
337
|
export function ensurePersistedAuthority(
|
|
348
338
|
dir: string,
|
|
349
339
|
options: AuthorityOptions,
|
|
350
340
|
lockName = "ca-publication.sqlite",
|
|
351
341
|
accept?: (cert: X509Certificate) => boolean,
|
|
352
342
|
): LocalInterceptCa {
|
|
353
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
354
343
|
// A separate SQLite namespace binds exclusion to the explicit CA directory.
|
|
355
344
|
// The OS releases it on crash; a contending caller fails before touching either
|
|
356
345
|
// PEM. Readers also take the lease so they cannot observe half a publication.
|
|
357
|
-
return
|
|
358
|
-
const existing = loadPersistedCa(
|
|
346
|
+
return withLocalCaPublication(dir, lockName, files => {
|
|
347
|
+
const existing = loadPersistedCa(files, accept);
|
|
359
348
|
if (existing) return existing;
|
|
360
349
|
const ca = createCertificateAuthority(options);
|
|
361
|
-
|
|
362
|
-
writeFileAtomic(join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE), ca.certPem, 0o644);
|
|
350
|
+
files.writePair(ca);
|
|
363
351
|
return ca;
|
|
364
|
-
}
|
|
352
|
+
});
|
|
365
353
|
}
|
|
366
354
|
|
|
367
355
|
/** Preserve the original intercept CA path, name, permissions and extension set. */
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { X509Certificate } from "node:crypto";
|
|
2
|
+
import { constants, closeSync, fstatSync, lstatSync, openSync, readSync, realpathSync, rmSync, writeFileSync, type Stats } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
decodePickerCaCredential, openPickerCaCredential, pickerCaConfigId, readPickerCaCredential,
|
|
6
|
+
writePickerCaCredential, type PickerCaStore, type StoredPickerCa,
|
|
7
|
+
} from "./picker-ca-store";
|
|
8
|
+
|
|
9
|
+
const MAX_PUBLIC_STATE_BYTES = 64 * 1024;
|
|
10
|
+
interface AuthorityMetadata { schema: 1; configId: string; fingerprint: string }
|
|
11
|
+
interface Initialization extends AuthorityMetadata { predecessor: string | null }
|
|
12
|
+
|
|
13
|
+
export function canonicalPickerConfigDir(configDir: string): string {
|
|
14
|
+
const canonical = realpathSync(configDir);
|
|
15
|
+
const stat = lstatSync(canonical);
|
|
16
|
+
if (!stat.isDirectory() || (process.platform !== "win32" && stat.uid !== process.getuid!())) {
|
|
17
|
+
throw new Error("picker_ca_config_unsafe");
|
|
18
|
+
}
|
|
19
|
+
return canonical;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Public records still cannot follow symlinks or accept oversized/malformed recovery data. */
|
|
23
|
+
function readState(path: string, configId: string, initialization: boolean): Initialization | AuthorityMetadata | null {
|
|
24
|
+
let expected: Stats;
|
|
25
|
+
try { expected = lstatSync(path); }
|
|
26
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw new Error("picker_ca_metadata_unsafe"); }
|
|
27
|
+
if (expected.isSymbolicLink()) throw new Error("picker_ca_metadata_unsafe");
|
|
28
|
+
let fd: number;
|
|
29
|
+
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
30
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw new Error("picker_ca_metadata_unsafe"); }
|
|
31
|
+
try {
|
|
32
|
+
const stat = fstatSync(fd);
|
|
33
|
+
if (stat.dev !== expected.dev || stat.ino !== expected.ino) throw new Error("picker_ca_metadata_unsafe");
|
|
34
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > MAX_PUBLIC_STATE_BYTES
|
|
35
|
+
|| (process.platform !== "win32" && stat.uid !== process.getuid!())) throw new Error();
|
|
36
|
+
const bytes = Buffer.alloc(MAX_PUBLIC_STATE_BYTES + 1);
|
|
37
|
+
let used = 0;
|
|
38
|
+
while (used < bytes.length) {
|
|
39
|
+
const count = readSync(fd, bytes, used, bytes.length - used, null);
|
|
40
|
+
if (count === 0) break;
|
|
41
|
+
used += count;
|
|
42
|
+
}
|
|
43
|
+
if (used > MAX_PUBLIC_STATE_BYTES) throw new Error();
|
|
44
|
+
const raw = bytes.subarray(0, used).toString("utf8");
|
|
45
|
+
const value = JSON.parse(raw);
|
|
46
|
+
const keys = initialization ? "configId,fingerprint,predecessor,schema" : "configId,fingerprint,schema";
|
|
47
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)
|
|
48
|
+
|| Object.keys(value).sort().join(",") !== keys || value.schema !== 1 || value.configId !== configId
|
|
49
|
+
|| typeof value.fingerprint !== "string" || !/^[A-F0-9]{64}$/.test(value.fingerprint)
|
|
50
|
+
|| (initialization && value.predecessor !== null && typeof value.predecessor !== "string")) throw new Error();
|
|
51
|
+
if (initialization && value.predecessor !== null
|
|
52
|
+
&& new X509Certificate(value.predecessor).toString() !== value.predecessor) throw new Error();
|
|
53
|
+
return value;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error instanceof Error && error.message === "picker_ca_metadata_unsafe") throw error;
|
|
56
|
+
throw new Error("picker_ca_metadata_invalid");
|
|
57
|
+
}
|
|
58
|
+
finally { closeSync(fd); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PersistentPickerCaOptions {
|
|
62
|
+
configDir: string;
|
|
63
|
+
stateDir: string;
|
|
64
|
+
store?: PickerCaStore;
|
|
65
|
+
acceptsAuthority: (pem: string) => boolean;
|
|
66
|
+
create: () => StoredPickerCa;
|
|
67
|
+
/** Refuses a different live predecessor BEFORE the journal or credential can be written. */
|
|
68
|
+
predecessor: (ca: StoredPickerCa) => string | null;
|
|
69
|
+
publish: (ca: StoredPickerCa, predecessor: string | null) => void;
|
|
70
|
+
publishMetadata: (path: string, value: string) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Caller holds the canonical picker CA lease throughout the store/publication transaction. */
|
|
74
|
+
export function ensurePersistentPickerCa(options: PersistentPickerCaOptions): StoredPickerCa {
|
|
75
|
+
const configId = pickerCaConfigId(options.configDir);
|
|
76
|
+
const metadataPath = join(options.stateDir, "authority.json");
|
|
77
|
+
const journalPath = join(options.stateDir, "authority-init.json");
|
|
78
|
+
const metadata = readState(metadataPath, configId, false);
|
|
79
|
+
let journal = readState(journalPath, configId, true) as Initialization | null;
|
|
80
|
+
const entry = openPickerCaCredential(configId, options.store);
|
|
81
|
+
const raw = readPickerCaCredential(entry);
|
|
82
|
+
let ca: StoredPickerCa;
|
|
83
|
+
if (raw === null) {
|
|
84
|
+
if (metadata || journal) throw new Error("picker_ca_store_missing");
|
|
85
|
+
ca = options.create();
|
|
86
|
+
const predecessor = options.predecessor(ca);
|
|
87
|
+
journal = { schema: 1, configId, fingerprint: ca.fingerprint, predecessor };
|
|
88
|
+
// Exclusive evidence is durable before ANY credential mutation. Never erase it on failure.
|
|
89
|
+
writeFileSync(journalPath, JSON.stringify(journal) + "\n", { flag: "wx", mode: 0o600 });
|
|
90
|
+
writePickerCaCredential(entry, configId, ca);
|
|
91
|
+
} else {
|
|
92
|
+
ca = decodePickerCaCredential(raw, configId, options.acceptsAuthority);
|
|
93
|
+
if ((!metadata && !journal) || (metadata && metadata.fingerprint !== ca.fingerprint)
|
|
94
|
+
|| (journal && journal.fingerprint !== ca.fingerprint)) throw new Error("picker_ca_metadata_mismatch");
|
|
95
|
+
const predecessor = options.predecessor(ca);
|
|
96
|
+
if (journal) {
|
|
97
|
+
// A crash may have published the new certificate already, but no different predecessor
|
|
98
|
+
// may be adopted under an existing journal. Public evidence must be exact and key-free.
|
|
99
|
+
if (predecessor !== null && predecessor !== journal.predecessor) throw new Error("picker_ca_metadata_mismatch");
|
|
100
|
+
} else if (predecessor !== null) throw new Error("picker_ca_metadata_mismatch");
|
|
101
|
+
}
|
|
102
|
+
options.publishMetadata(metadataPath, JSON.stringify({ schema: 1, configId, fingerprint: ca.fingerprint }) + "\n");
|
|
103
|
+
options.publish(ca, journal?.predecessor ?? null);
|
|
104
|
+
if (journal) rmSync(journalPath); // Last: store, metadata, certificate, owner, and cleanup evidence now exist.
|
|
105
|
+
return ca;
|
|
106
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ensurePickerCa, type PickerCa } from "./picker-ca";
|
|
2
|
+
import { drainPendingPickerCaUntrust } from "./picker-ca-cleanup";
|
|
3
|
+
import type { PickerCaStore } from "./picker-ca-store";
|
|
4
|
+
import type { SecurityRunner } from "./picker-trust";
|
|
5
|
+
|
|
6
|
+
/** Internal activation seam: finish legacy cleanup before a persistent issuer is usable. */
|
|
7
|
+
export async function preparePersistentPickerAuthority(options: {
|
|
8
|
+
configDir: string; store?: PickerCaStore; security?: SecurityRunner; platform: NodeJS.Platform;
|
|
9
|
+
}): Promise<PickerCa> {
|
|
10
|
+
if (options.platform !== "darwin") throw new Error("picker_ca_unsupported_platform");
|
|
11
|
+
const drain = () => drainPendingPickerCaUntrust(options.configDir, options.security, options.platform);
|
|
12
|
+
if (!await drain()) throw new Error("picker_ca_pending_untrust");
|
|
13
|
+
const ca = ensurePickerCa(options.configDir, { rotation: "startup", persistent: true, store: options.store });
|
|
14
|
+
if (!await drain()) throw new Error("picker_ca_pending_untrust");
|
|
15
|
+
return ca;
|
|
16
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, X509Certificate } from "node:crypto";
|
|
2
|
+
import { loadKeyringBinding } from "../../lib/keyring-native";
|
|
3
|
+
import type { LocalInterceptCa } from "./local-ca";
|
|
4
|
+
|
|
5
|
+
export const PICKER_CA_STORE_SERVICE = "opencodex.claude-desktop-picker.ca.v1";
|
|
6
|
+
const MAX_PAYLOAD_BYTES = 16 * 1024;
|
|
7
|
+
export interface PickerCaCredential {
|
|
8
|
+
getPassword(): string | null;
|
|
9
|
+
setPassword(value: string): void;
|
|
10
|
+
}
|
|
11
|
+
export type PickerCaStore = (service: string, account: string) => PickerCaCredential;
|
|
12
|
+
export interface StoredPickerCa extends LocalInterceptCa { fingerprint: string }
|
|
13
|
+
|
|
14
|
+
export function pickerCaConfigId(canonicalConfigDir: string): string {
|
|
15
|
+
return createHash("sha256").update(canonicalConfigDir).digest("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function nativeStore(service: string, account: string): PickerCaCredential {
|
|
19
|
+
const { Entry } = loadKeyringBinding();
|
|
20
|
+
// The shared loader is deliberately binding-agnostic; this is the sync Entry contract.
|
|
21
|
+
return new Entry(service, account) as PickerCaCredential;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function openPickerCaCredential(configId: string, store: PickerCaStore = nativeStore): PickerCaCredential {
|
|
25
|
+
try { return store(PICKER_CA_STORE_SERVICE, configId); }
|
|
26
|
+
catch { throw new Error("picker_ca_store_unavailable"); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readPickerCaCredential(entry: PickerCaCredential): string | null {
|
|
30
|
+
try {
|
|
31
|
+
const value = entry.getPassword();
|
|
32
|
+
if (value === null) return null;
|
|
33
|
+
if (typeof value !== "string") throw new Error();
|
|
34
|
+
return value;
|
|
35
|
+
} catch { throw new Error("picker_ca_store_unavailable"); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** OS credential contents are an untrusted, bounded, exact-shape boundary. */
|
|
39
|
+
export function decodePickerCaCredential(
|
|
40
|
+
raw: string, configId: string, acceptsAuthority: (pem: string) => boolean,
|
|
41
|
+
): StoredPickerCa {
|
|
42
|
+
try {
|
|
43
|
+
if (Buffer.byteLength(raw) > MAX_PAYLOAD_BYTES) throw new Error();
|
|
44
|
+
const value = JSON.parse(raw);
|
|
45
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)
|
|
46
|
+
|| Object.keys(value).sort().join(",") !== "certPem,configId,fingerprint,keyPem,schema"
|
|
47
|
+
|| value.schema !== 1 || value.configId !== configId
|
|
48
|
+
|| typeof value.certPem !== "string" || typeof value.keyPem !== "string"
|
|
49
|
+
|| typeof value.fingerprint !== "string" || !/^[A-F0-9]{64}$/.test(value.fingerprint)) throw new Error();
|
|
50
|
+
const cert = new X509Certificate(value.certPem);
|
|
51
|
+
const privateKey = createPrivateKey(value.keyPem);
|
|
52
|
+
const publicKey = createPublicKey(value.keyPem);
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
if (cert.toString() !== value.certPem || !acceptsAuthority(value.certPem)
|
|
55
|
+
|| !cert.ca || !cert.checkPrivateKey(privateKey) || !cert.verify(publicKey)
|
|
56
|
+
|| privateKey.asymmetricKeyType !== "ec" || privateKey.asymmetricKeyDetails?.namedCurve !== "prime256v1"
|
|
57
|
+
|| privateKey.export({ type: "pkcs8", format: "pem" }) !== value.keyPem
|
|
58
|
+
|| !(Date.parse(cert.validFrom) <= now && now < Date.parse(cert.validTo))
|
|
59
|
+
|| createHash("sha256").update(cert.raw).digest("hex").toUpperCase() !== value.fingerprint) throw new Error();
|
|
60
|
+
return { certPem: value.certPem, keyPem: value.keyPem, fingerprint: value.fingerprint, privateKey, publicKey };
|
|
61
|
+
} catch { throw new Error("picker_ca_store_invalid"); }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function writePickerCaCredential(entry: PickerCaCredential, configId: string, ca: StoredPickerCa): void {
|
|
65
|
+
const raw = JSON.stringify({ schema: 1, configId, fingerprint: ca.fingerprint, certPem: ca.certPem, keyPem: ca.keyPem });
|
|
66
|
+
if (Buffer.byteLength(raw) > MAX_PAYLOAD_BYTES) throw new Error("picker_ca_store_invalid");
|
|
67
|
+
try { entry.setPassword(raw); }
|
|
68
|
+
catch { throw new Error("picker_ca_store_unavailable"); }
|
|
69
|
+
if (readPickerCaCredential(entry) !== raw) throw new Error("picker_ca_store_readback_failed");
|
|
70
|
+
}
|