@hellolynk-ai/codex-plugin 0.2.102 → 0.2.103-macos.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -17
- package/bin/hellolynk-client-setup.mjs +70 -10
- package/hellolynk-client-pack-manifest.json +101 -61
- package/hellolynk-client-pack-pin.json +1 -1
- package/package.json +8 -4
- package/plugins/hellolynk-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/hellolynk-codex/.mcp.json +20 -19
- package/plugins/hellolynk-codex/package.json +1 -1
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-darwin-host.mjs +269 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-darwin-vault.mjs +153 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +20 -8
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +68 -14
- package/plugins/hellolynk-codex/runtime/bootstrap/platform/darwin/hellolynk-keychain-helper.bin +0 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/platform/darwin/hellolynk-keychain-helper.swift +149 -0
- package/plugins/hellolynk-codex/runtime/connect-activate.mjs +5 -1
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +7 -3
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +62 -33
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +62 -18
- package/plugins/hellolynk-codex/runtime/core/hellolynk-darwin-host.mjs +269 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-darwin-vault.mjs +153 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +20 -8
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +53 -6
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +22 -7
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +7 -5
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +9 -3
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +68 -14
- package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +101 -2
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +5 -3
- package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +1 -1
- package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +56 -24
- package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +1 -1
- package/plugins/hellolynk-codex/runtime/platform/darwin/hellolynk-keychain-helper.bin +0 -0
- package/plugins/hellolynk-codex/runtime/platform/darwin/hellolynk-keychain-helper.swift +149 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +46 -10
- package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +14 -8
- package/tools/hellolynk-activate-neutral-host-clients.mjs +5 -1
|
@@ -6,20 +6,39 @@ import { withNeutralHostStoreLock } from "./hellolynk-neutral-host-installation-
|
|
|
6
6
|
|
|
7
7
|
const CATALOG = "https://hellolynk.com/downloads/clients.json";
|
|
8
8
|
export const CLIENT_UPDATE_CLIENTS = Object.freeze(["codex", "openclaw", "trae-work", "workbuddy", "dsh", "claude-code"]);
|
|
9
|
+
export const MAC_CLIENT_VERSION = /^\d+\.\d+\.\d+-macos\.[1-9]\d*$/u;
|
|
9
10
|
const HOUR = 3_600_000;
|
|
10
11
|
const MAX_ARTIFACT = 64 * 1024 * 1024;
|
|
11
12
|
const HASH = /^[a-f0-9]{64}$/;
|
|
12
|
-
const CONTRACT = "hellolynk-client-update-
|
|
13
|
+
const CONTRACT = "hellolynk-client-update-v2";
|
|
14
|
+
export const CLIENT_UPDATE_RELEASES = "releases-v2";
|
|
15
|
+
// The release freeze pins only public Ed25519 keys here. An empty set refuses all updates.
|
|
16
|
+
export const CLIENT_UPDATE_CATALOG_KEYS = Object.freeze({
|
|
17
|
+
"ed25519-20261109-primary-d4fd7f46475dac48": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAucQohi+be+ksjQhRzYD8ZiyZMUz6Vi/DSphQ3iVOWDM=\n-----END PUBLIC KEY-----\n",
|
|
18
|
+
"ed25519-20261109-backup-5e3d08a775320304": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA2vxHpcgLZQc1cXqxq3dcmZRmE0fAFyeRC3umMVGAosA=\n-----END PUBLIC KEY-----\n"
|
|
19
|
+
});
|
|
20
|
+
export const CLIENT_UPDATE_CATALOG_LIMIT = 256 * 1024;
|
|
13
21
|
// A failed integrity or path check keeps a downloaded package from installing. Every other failure is transient.
|
|
14
|
-
const BLOCKING = /^client_update_(?:same_version_changed|version_rollback|artifact_integrity_invalid|archive_[a-z_]+|path_invalid|url_invalid|redirect_invalid)$/;
|
|
22
|
+
const BLOCKING = /^client_update_(?:catalog_signature_invalid|same_version_changed|version_rollback|artifact_integrity_invalid|archive_[a-z_]+|path_invalid|url_invalid|redirect_invalid)$/;
|
|
23
|
+
|
|
24
|
+
export function clientUpdateCatalogId(client, platform) {
|
|
25
|
+
if (platform === "win32") return client;
|
|
26
|
+
if (platform === "darwin" && (client === "codex" || client === "claude-code")) return `${client}-macos`;
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
15
29
|
|
|
16
30
|
// This component only stages verified files. Host lifecycle and client-native
|
|
17
31
|
// installation remain the responsibility of the caller.
|
|
18
32
|
export function createClientUpdateManager(options = {}) {
|
|
19
33
|
const { client, currentVersion, platform = process.platform } = options;
|
|
20
34
|
if (!CLIENT_UPDATE_CLIENTS.includes(client) || !semver(currentVersion) || !path.isAbsolute(String(options.root || ""))) fail("configuration_invalid");
|
|
35
|
+
const catalogId = clientUpdateCatalogId(client, platform);
|
|
36
|
+
const mac = platform === "darwin";
|
|
37
|
+
if (mac && !MAC_CLIENT_VERSION.test(currentVersion)) fail("configuration_invalid");
|
|
21
38
|
const root = path.join(path.resolve(options.root), "client-updates", client);
|
|
22
|
-
|
|
39
|
+
// A still-running unsigned updater may read, rewrite and prune its own cache.
|
|
40
|
+
const stateFile = path.join(root, "state-v2.json");
|
|
41
|
+
const releases = path.join(root, CLIENT_UPDATE_RELEASES);
|
|
23
42
|
const fetcher = options.fetchImpl || globalThis.fetch;
|
|
24
43
|
const clock = options.clock || Date.now;
|
|
25
44
|
const timeoutMs = bounded(options.timeoutMs, 30_000, 50, 120_000);
|
|
@@ -60,7 +79,7 @@ export function createClientUpdateManager(options = {}) {
|
|
|
60
79
|
}
|
|
61
80
|
}
|
|
62
81
|
|
|
63
|
-
function releasePath(record) { return path.join(
|
|
82
|
+
function releasePath(record) { return path.join(releases, `${record.version}-${record.sha256}`); }
|
|
64
83
|
async function save(record) {
|
|
65
84
|
await safeDirectory(root);
|
|
66
85
|
const temporary = path.join(root, `.state-${crypto.randomUUID()}.tmp`);
|
|
@@ -80,12 +99,13 @@ export function createClientUpdateManager(options = {}) {
|
|
|
80
99
|
let old = await readState();
|
|
81
100
|
const now = clock();
|
|
82
101
|
if (!force && old.checkedAt > 0 && now >= old.checkedAt && now - old.checkedAt < 23 * HOUR) return old;
|
|
83
|
-
if (
|
|
102
|
+
if (!catalogId) return save({ ...old, stage: "unsupported", checkedAt: now });
|
|
84
103
|
try {
|
|
85
|
-
const metadata = await request(CATALOG, { limit:
|
|
104
|
+
const metadata = await request(CATALOG, { limit: CLIENT_UPDATE_CATALOG_LIMIT, timeout: timeoutMs, kind: "catalog" });
|
|
86
105
|
let catalog;
|
|
87
106
|
try { catalog = JSON.parse(metadata.toString("utf8")); } catch { fail("catalog_invalid"); }
|
|
88
|
-
const
|
|
107
|
+
const verified = verifyClientUpdateCatalog(catalog, options.catalogKeys);
|
|
108
|
+
const artifact = validateClientUpdateCatalogEntry(verified, catalogId, mac);
|
|
89
109
|
// The catalog has no build for this client: there is nothing to install.
|
|
90
110
|
if (!artifact) {
|
|
91
111
|
// A downloaded package keeps its stage and any integrity block; only an empty cache is "current".
|
|
@@ -134,12 +154,12 @@ export function createClientUpdateManager(options = {}) {
|
|
|
134
154
|
if (!bytes) fail("source_unavailable");
|
|
135
155
|
const release = releasePath(artifact);
|
|
136
156
|
await safeDirectory(path.dirname(release));
|
|
137
|
-
const stage = path.join(
|
|
157
|
+
const stage = path.join(releases, `.stage-${crypto.randomUUID()}`);
|
|
138
158
|
await fs.mkdir(stage, { mode: 0o700 });
|
|
139
159
|
try {
|
|
140
160
|
const archivePath = path.join(stage, artifact.filename);
|
|
141
161
|
await fs.writeFile(archivePath, bytes, { flag: "wx", mode: 0o600 });
|
|
142
|
-
await extractClientUpdateArchive({ archivePath, destination: path.join(stage, "extracted") });
|
|
162
|
+
await extractClientUpdateArchive({ archivePath, destination: path.join(stage, "extracted"), platform });
|
|
143
163
|
await fs.rename(stage, release).catch(async (error) => {
|
|
144
164
|
if (!["EEXIST", "ENOTEMPTY", "EPERM"].includes(error.code)) throw error;
|
|
145
165
|
try {
|
|
@@ -147,7 +167,7 @@ export function createClientUpdateManager(options = {}) {
|
|
|
147
167
|
return;
|
|
148
168
|
} catch { /* Preserve damaged evidence without reusing it. */ }
|
|
149
169
|
await safeAncestors(release, true);
|
|
150
|
-
const quarantine = path.join(
|
|
170
|
+
const quarantine = path.join(releases, `.corrupt-${artifact.sha256}-${crypto.randomUUID()}`);
|
|
151
171
|
await fs.rename(release, quarantine);
|
|
152
172
|
try { await fs.rename(stage, release); }
|
|
153
173
|
catch (replacement) { await fs.rename(quarantine, release).catch(() => {}); throw replacement; }
|
|
@@ -172,7 +192,7 @@ export function createClientUpdateManager(options = {}) {
|
|
|
172
192
|
});
|
|
173
193
|
}
|
|
174
194
|
|
|
175
|
-
// After an install only the package state
|
|
195
|
+
// After an install only the package the signed state names stays cached; older
|
|
176
196
|
// versions and quarantined copies go. Holding the check lock means no
|
|
177
197
|
// download is in flight to be mistaken for leftovers.
|
|
178
198
|
async function prune() {
|
|
@@ -180,7 +200,7 @@ export function createClientUpdateManager(options = {}) {
|
|
|
180
200
|
return withNeutralHostStoreLock(root, "client-update", async () => {
|
|
181
201
|
const state = await readState();
|
|
182
202
|
if (!semver(state.version) || !HASH.test(String(state.sha256 || ""))) return [];
|
|
183
|
-
const keep = path.basename(releasePath(state)),
|
|
203
|
+
const keep = path.basename(releasePath(state)), removed = [];
|
|
184
204
|
await safeAncestors(releases, true);
|
|
185
205
|
let entries;
|
|
186
206
|
try { entries = await fs.readdir(releases, { withFileTypes: true }); }
|
|
@@ -267,15 +287,36 @@ export function createClientUpdateManager(options = {}) {
|
|
|
267
287
|
});
|
|
268
288
|
}
|
|
269
289
|
|
|
270
|
-
function
|
|
290
|
+
export function verifyClientUpdateCatalog(catalog, keys = CLIENT_UPDATE_CATALOG_KEYS) {
|
|
291
|
+
const signed = catalog?.signed;
|
|
292
|
+
if (!signed || typeof signed.keyId !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(signed.keyId)
|
|
293
|
+
|| !Object.hasOwn(keys, signed.keyId) || typeof signed.payload !== "string" || !signed.payload
|
|
294
|
+
|| signed.payload.length > CLIENT_UPDATE_CATALOG_LIMIT || typeof signed.signature !== "string"
|
|
295
|
+
|| !/^[A-Za-z0-9_-]{86}$/.test(signed.signature)) fail("catalog_signature_invalid");
|
|
296
|
+
const payload = Buffer.from(signed.payload, "base64");
|
|
297
|
+
const signature = Buffer.from(signed.signature, "base64url");
|
|
298
|
+
if (payload.toString("base64") !== signed.payload || signature.toString("base64url") !== signed.signature) fail("catalog_signature_invalid");
|
|
299
|
+
let valid = false;
|
|
300
|
+
try {
|
|
301
|
+
const key = crypto.createPublicKey(keys[signed.keyId]);
|
|
302
|
+
valid = key.asymmetricKeyType === "ed25519" && crypto.verify(null, payload, key, signature);
|
|
303
|
+
} catch { /* Invalid pins and invalid signatures both fail closed. */ }
|
|
304
|
+
if (!valid) fail("catalog_signature_invalid");
|
|
305
|
+
const json = payload.toString("utf8");
|
|
306
|
+
if (!Buffer.from(json, "utf8").equals(payload)) fail("catalog_invalid");
|
|
307
|
+
try { return JSON.parse(json); } catch { fail("catalog_invalid"); }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function validateClientUpdateCatalogEntry(catalog, client, mac) {
|
|
271
311
|
if (!catalog || !Array.isArray(catalog.clients) || catalog.clients.length > 32) fail("catalog_invalid");
|
|
272
312
|
const matches = catalog.clients.filter((item) => item?.client === client);
|
|
273
313
|
if (matches.length === 0) return null;
|
|
274
314
|
if (matches.length > 1) fail("catalog_invalid");
|
|
275
315
|
const item = matches[0];
|
|
276
|
-
if (!semver(item.version) || !
|
|
316
|
+
if (!semver(item.version) || (mac && !MAC_CLIENT_VERSION.test(item.version))
|
|
317
|
+
|| !HASH.test(String(item.sha256 || "")) || !safeFilename(item.filename)
|
|
277
318
|
|| !Number.isSafeInteger(item.bytes) || item.bytes < 4 || item.bytes > MAX_ARTIFACT
|
|
278
|
-
|| !Array.isArray(item.platforms) || !item.platforms.includes("Windows")) fail("catalog_invalid");
|
|
319
|
+
|| !Array.isArray(item.platforms) || !item.platforms.includes(mac ? "macOS" : "Windows")) fail("catalog_invalid");
|
|
279
320
|
const official = new URL(String(item.url || ""), CATALOG);
|
|
280
321
|
if (official.origin !== "https://hellolynk.com" || !official.pathname.startsWith("/downloads/")) fail("catalog_invalid");
|
|
281
322
|
validateUrl(official, "artifact");
|
|
@@ -309,7 +350,7 @@ function validateUrl(url, kind, redirected = false, initial = url) {
|
|
|
309
350
|
if (!direct && !githubCdn) fail("url_invalid");
|
|
310
351
|
}
|
|
311
352
|
|
|
312
|
-
export async function extractClientUpdateArchive({ archivePath, destination, maxTotalBytes = 128 * 1024 * 1024, maxFileBytes = 32 * 1024 * 1024, maxEntries = 4096 }) {
|
|
353
|
+
export async function extractClientUpdateArchive({ archivePath, destination, platform = process.platform, maxTotalBytes = 128 * 1024 * 1024, maxFileBytes = 32 * 1024 * 1024, maxEntries = 4096 }) {
|
|
313
354
|
if (!path.isAbsolute(archivePath) || !path.isAbsolute(destination)) fail("archive_path_invalid");
|
|
314
355
|
await safeAncestors(archivePath, false);
|
|
315
356
|
const stat = await fs.lstat(archivePath);
|
|
@@ -360,7 +401,7 @@ export async function extractClientUpdateArchive({ archivePath, destination, max
|
|
|
360
401
|
total += size;
|
|
361
402
|
if (total > maxTotalBytes) fail("archive_too_large");
|
|
362
403
|
spans.push([local, start + packed]);
|
|
363
|
-
entries.push({ segments, directory, start, packed, size, method, crc });
|
|
404
|
+
entries.push({ segments, directory, start, packed, size, method, crc, mode });
|
|
364
405
|
cursor += 46 + nameLength + extraLength + commentLength;
|
|
365
406
|
}
|
|
366
407
|
if (cursor !== eocd) fail("archive_invalid");
|
|
@@ -377,7 +418,10 @@ export async function extractClientUpdateArchive({ archivePath, destination, max
|
|
|
377
418
|
try { body = entry.method === 0 ? compressed : inflateRawSync(compressed, { maxOutputLength: Math.max(1, entry.size) }); }
|
|
378
419
|
catch { fail("archive_integrity_invalid"); }
|
|
379
420
|
if (body.length !== entry.size || crc32(body) !== entry.crc) fail("archive_integrity_invalid");
|
|
380
|
-
|
|
421
|
+
// A Mac update runs a bundled Keychain helper. Keep its execute bit without
|
|
422
|
+
// inheriting group/other access or special permission bits from the ZIP.
|
|
423
|
+
const ownerExecute = platform === "darwin" ? entry.mode & 0o100 : 0;
|
|
424
|
+
await fs.writeFile(target, body, { flag: "wx", mode: 0o600 | ownerExecute });
|
|
381
425
|
}
|
|
382
426
|
return Object.freeze({ files: entries.filter((item) => !item.directory).length, bytes: total });
|
|
383
427
|
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { isDeepStrictEqual as equal } from "node:util";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { checkDarwinPath, vaultError, withDarwinVault } from "./hellolynk-darwin-vault.mjs";
|
|
4
|
+
|
|
5
|
+
const permanent = 253402300799999;
|
|
6
|
+
const month = 30 * 86400000;
|
|
7
|
+
function fail(code) { throw vaultError(code); }
|
|
8
|
+
function fieldsMatch(a, b, fields) { return fields.every(field => equal(a[field], b[field])); }
|
|
9
|
+
function sameOwner(a, b) { return fieldsMatch(a, b, ["principalId", "hostInstallationId"]); }
|
|
10
|
+
function used(records, field, id) { return records.some(r => r[field].toLowerCase() === id.toLowerCase()); }
|
|
11
|
+
function unique(records, missing, ambiguous) {
|
|
12
|
+
if (records.length !== 1) fail(records.length ? ambiguous : missing);
|
|
13
|
+
return records[0];
|
|
14
|
+
}
|
|
15
|
+
function checkBindingPaths(b, code) {
|
|
16
|
+
try {
|
|
17
|
+
checkDarwinPath(b.runtimeRoot, { directory: true });
|
|
18
|
+
checkDarwinPath(b.releaseRoot, { directory: true });
|
|
19
|
+
if (checkDarwinPath(b.profileManifestPath).size > 65536 ||
|
|
20
|
+
checkDarwinPath(join(b.releaseRoot, b.runnerRelativePath)).size > 1048576) fail(code);
|
|
21
|
+
} catch { fail(code); }
|
|
22
|
+
}
|
|
23
|
+
function capMatches(cap, b) {
|
|
24
|
+
return cap.state === "active" && fieldsMatch(cap, b,
|
|
25
|
+
["region", "principalId", "delegateId", "hostInstallationId", "adapterId", "version", "scopes"]);
|
|
26
|
+
}
|
|
27
|
+
function addCapability(s, r) {
|
|
28
|
+
if (used(s.caps, "capabilityId", r.capabilityId)) fail("host_capability_id_conflict");
|
|
29
|
+
s.caps.push(r);
|
|
30
|
+
}
|
|
31
|
+
function revokeCapability(s, r) {
|
|
32
|
+
if (!r) fail("host_capability_missing");
|
|
33
|
+
if (r.state === "active") {
|
|
34
|
+
const revokedAt = Date.now();
|
|
35
|
+
if (revokedAt < r.issuedAt) fail("host_capability_state_invalid");
|
|
36
|
+
// Keep the identifier spent while discarding the retired secret.
|
|
37
|
+
r = s.caps[s.caps.indexOf(r)] = { capabilityId: r.capabilityId, issuedAt: r.issuedAt, state: "revoked", revokedAt };
|
|
38
|
+
}
|
|
39
|
+
return { capabilityId: r.capabilityId, revokedAt: r.revokedAt, state: "revoked" };
|
|
40
|
+
}
|
|
41
|
+
function neutralBinding(s, adapter, action, input, oldId) {
|
|
42
|
+
const codex = adapter === "codex";
|
|
43
|
+
const key = codex ? "codex" : "claudeCode";
|
|
44
|
+
const error = suffix => `neutral_${adapter.replace("-", "_")}_${suffix}`;
|
|
45
|
+
const check = binding => checkBindingPaths(binding, error("launch_manifest_invalid"));
|
|
46
|
+
const b = s[key];
|
|
47
|
+
if (action === "Resolve" || action === "Verify") {
|
|
48
|
+
if (!b) fail(error("binding_configuration_required"));
|
|
49
|
+
// Claude retains its identity across uninstall; Verify can inspect a
|
|
50
|
+
// retained binding whose old release is gone. Resolve always needs it.
|
|
51
|
+
if (codex || action === "Resolve") check(b);
|
|
52
|
+
const active = s.caps.filter(c => c.adapterId === adapter && c.state === "active");
|
|
53
|
+
const cap = unique(active.filter(c => capMatches(c, b)),
|
|
54
|
+
error(active.length ? "capability_binding_mismatch" : "capability_missing"), error("capability_ambiguous"));
|
|
55
|
+
if (action === "Resolve") return { binding: b, record: cap };
|
|
56
|
+
const {secret, ...record} = cap;
|
|
57
|
+
return { binding: b, record };
|
|
58
|
+
}
|
|
59
|
+
if (action === "Save" || action === "Remove") {
|
|
60
|
+
if (input.adapterId !== adapter) fail(error("binding_invalid"));
|
|
61
|
+
if (action === "Remove") {
|
|
62
|
+
if (codex) check(input);
|
|
63
|
+
if (!equal(b, input)) fail(error("binding_invalid"));
|
|
64
|
+
if (codex) s.codex = null;
|
|
65
|
+
else delete s.claudeCode;
|
|
66
|
+
return { removed: true };
|
|
67
|
+
}
|
|
68
|
+
check(input);
|
|
69
|
+
if (b && !equal(b, input)) fail(error("binding_invalid"));
|
|
70
|
+
s[key] = input;
|
|
71
|
+
return { binding: input };
|
|
72
|
+
}
|
|
73
|
+
const { previousBinding: previous, replacementBinding: next, replacementRecord } = input;
|
|
74
|
+
if (codex) check(previous);
|
|
75
|
+
check(next);
|
|
76
|
+
if (next.adapterId !== adapter || !fieldsMatch(previous, next, ["contract","region","principalId","delegateId","hostInstallationId","profileId","adapterId","version","runtimeRoot"])) {
|
|
77
|
+
fail(error("capability_binding_mismatch"));
|
|
78
|
+
}
|
|
79
|
+
if (!equal(b, previous)) fail(error("binding_invalid"));
|
|
80
|
+
const active = s.caps.filter(c => capMatches(c, next));
|
|
81
|
+
if (codex && action === "Replace") {
|
|
82
|
+
if (!equal(previous.scopes, next.scopes)) fail(error("capability_binding_mismatch"));
|
|
83
|
+
if (active.length !== 1) fail(error("capability_ambiguous"));
|
|
84
|
+
s[key] = next;
|
|
85
|
+
return { binding: next };
|
|
86
|
+
}
|
|
87
|
+
if (action !== "Migrate") fail("host_protected_store_command_invalid");
|
|
88
|
+
const old = s.caps.find(c => c.capabilityId === oldId);
|
|
89
|
+
if (!old || !capMatches(old, previous) || !capMatches(replacementRecord, next)) fail(error("capability_binding_mismatch"));
|
|
90
|
+
const other = active.filter(c => c.capabilityId !== oldId);
|
|
91
|
+
if (other.length > 1) fail(error("capability_ambiguous"));
|
|
92
|
+
if (!other.length) addCapability(s, replacementRecord);
|
|
93
|
+
revokeCapability(s, old);
|
|
94
|
+
s[key] = next;
|
|
95
|
+
return { binding: next, record: other[0] || replacementRecord, replacedCapabilityId: oldId };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function retireSession(s, r) {
|
|
99
|
+
s.sessions.splice(s.sessions.indexOf(r), 1);
|
|
100
|
+
s.retiredSessions.push(r.sessionId.toLowerCase());
|
|
101
|
+
}
|
|
102
|
+
function revokeCredential(s, r, timestamp) {
|
|
103
|
+
if (timestamp < r.issuedAt) fail("host_transport_credential_revoke_timestamp_invalid");
|
|
104
|
+
const sessions = s.sessions.filter(session => session.credentialId === r.credentialId);
|
|
105
|
+
if (r.state === "active") {
|
|
106
|
+
sessions.forEach(session => retireSession(s, session));
|
|
107
|
+
const { credentialId, issuedAt, principalId, hostInstallationId } = r;
|
|
108
|
+
r = s.credentials[s.credentials.indexOf(r)] = { credentialId, issuedAt, principalId, hostInstallationId, state:"revoked", revokedAt:timestamp };
|
|
109
|
+
}
|
|
110
|
+
return { credentialId:r.credentialId, state:"revoked", revokedAt:r.revokedAt, cascadedSessionCount:sessions.length };
|
|
111
|
+
}
|
|
112
|
+
function exactFields(value, fields) {
|
|
113
|
+
return value && typeof value === "object" && equal(Object.keys(value).sort(), fields.sort());
|
|
114
|
+
}
|
|
115
|
+
function checkEnrollment(value, record) {
|
|
116
|
+
const a = value?.approval, approved = a?.hostTransportCredential;
|
|
117
|
+
if (!exactFields(value, ["baseUrl","hellolynkId","hostId","deviceLabel","requestId","proof","approval","intents"])
|
|
118
|
+
|| !exactFields(a, ["status","principalId","hellolynkId","region","hostId","hostTransportCredential"])
|
|
119
|
+
|| !exactFields(approved, ["credentialId","credential","createdAt","expiresAt"])
|
|
120
|
+
|| a.status !== "approved" || a.principalId !== record.principalId || a.hostId !== record.hostInstallationId
|
|
121
|
+
|| value.hostId !== a.hostId || a.hellolynkId !== value.hellolynkId || approved.credentialId !== record.credentialId
|
|
122
|
+
|| approved.credential !== record.secret || Date.parse(approved.createdAt) !== record.issuedAt
|
|
123
|
+
|| Date.parse(approved.expiresAt) !== record.expiresAt) fail("device_enrollment_transport_approval_mismatch");
|
|
124
|
+
if (record.expiresAt <= Date.now()) fail("host_transport_credential_expired");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function transport(s, q, input) {
|
|
128
|
+
const {command, timestamp} = q;
|
|
129
|
+
const id = q.credentialId || input?.credentialId;
|
|
130
|
+
const credential = s.credentials.find(r => r.credentialId === id);
|
|
131
|
+
const owned = query => s.credentials.filter(r => r.state === "active" && sameOwner(r, query));
|
|
132
|
+
if (command === "SaveTransportCredential" || command === "SaveEnrollmentTransportCredential") {
|
|
133
|
+
const upgrade = command === "SaveEnrollmentTransportCredential";
|
|
134
|
+
if (upgrade) checkEnrollment(s.enrollment.value, input);
|
|
135
|
+
if (credential) {
|
|
136
|
+
if (!equal(credential,input)) fail("host_transport_credential_immutable_conflict");
|
|
137
|
+
} else {
|
|
138
|
+
if (used(s.credentials, "credentialId", id)) fail("host_transport_credential_immutable_conflict");
|
|
139
|
+
const old = owned(input);
|
|
140
|
+
if (old.length > 1 || (old.length && !upgrade)) fail("host_transport_credential_ambiguous");
|
|
141
|
+
if (old.length) revokeCredential(s,old[0],Date.now());
|
|
142
|
+
s.credentials.push(input);
|
|
143
|
+
}
|
|
144
|
+
return { record: input };
|
|
145
|
+
}
|
|
146
|
+
if (command === "VerifyTransportCredentialLifetimeMigration" || command === "MigrateTransportCredentialLifetime") {
|
|
147
|
+
const record = unique(owned(q), "host_transport_migration_credential_inactive_or_ambiguous", "host_transport_migration_credential_inactive_or_ambiguous");
|
|
148
|
+
if (record.expiresAt !== permanent && record.expiresAt - record.issuedAt !== month) fail("host_transport_migration_lifetime_invalid");
|
|
149
|
+
if (command.startsWith("Verify")) return { record };
|
|
150
|
+
if (record.expiresAt-record.issuedAt !== month || !equal(record,input.previous)) fail("host_transport_migration_compare_failed");
|
|
151
|
+
const proof = input.proof;
|
|
152
|
+
if (proof.credentialId !== record.credentialId || proof.hostId !== q.hostInstallationId || proof.principalId !== q.principalId
|
|
153
|
+
|| Date.parse(proof.credentialCreatedAt) !== record.issuedAt || Date.parse(proof.credentialExpiresAt) !== permanent) fail("host_transport_migration_proof_invalid");
|
|
154
|
+
record.expiresAt = permanent;
|
|
155
|
+
return { credentialId: record.credentialId, expiresAt: permanent, status:"migrated" };
|
|
156
|
+
}
|
|
157
|
+
if (command === "LoadTransportCredential" || command === "RevokeTransportCredential") {
|
|
158
|
+
if (command.startsWith("Load") && credential?.state !== "active") fail("host_transport_credential_inactive");
|
|
159
|
+
if (!credential) fail("host_transport_credential_missing");
|
|
160
|
+
if (!sameOwner(credential,q)) fail("host_transport_credential_owner_mismatch");
|
|
161
|
+
if (command.startsWith("Revoke")) return revokeCredential(s,credential,timestamp);
|
|
162
|
+
if (credential.expiresAt <= timestamp) fail("host_transport_credential_expired");
|
|
163
|
+
return { record: credential };
|
|
164
|
+
}
|
|
165
|
+
const sessionId = q.sessionId || input?.sessionId;
|
|
166
|
+
const session = s.sessions.find(r => r.sessionId === sessionId);
|
|
167
|
+
if (command === "SaveTransportSession" || command === "ReplaceTransportSession") {
|
|
168
|
+
if (credential?.state !== "active") fail("host_transport_credential_inactive");
|
|
169
|
+
if (!sameOwner(credential,input) || credential.expiresAt <= timestamp || input.expiresAt > credential.expiresAt) fail("host_transport_session_credential_mismatch");
|
|
170
|
+
if (s.retiredSessions.includes(sessionId.toLowerCase())) fail("host_transport_session_retired");
|
|
171
|
+
if (session) {
|
|
172
|
+
if (!equal(session,input)) fail("host_transport_session_immutable_conflict");
|
|
173
|
+
} else {
|
|
174
|
+
if (used(s.sessions, "sessionId", sessionId)) fail("host_transport_session_duplicate");
|
|
175
|
+
const previous = s.sessions.filter(r => sameOwner(r,input));
|
|
176
|
+
if (previous.length && command.startsWith("Save")) fail("host_transport_session_ambiguous");
|
|
177
|
+
previous.forEach(r => retireSession(s,r));
|
|
178
|
+
s.sessions.push(input);
|
|
179
|
+
}
|
|
180
|
+
return { record: input };
|
|
181
|
+
}
|
|
182
|
+
if (command === "LoadTransportSession" || command === "RemoveTransportSession") {
|
|
183
|
+
if (session && !sameOwner(session,q)) fail("host_transport_session_owner_mismatch");
|
|
184
|
+
if (command.startsWith("Remove")) {
|
|
185
|
+
if (session) retireSession(s,session);
|
|
186
|
+
return { sessionId, removed: Boolean(session) };
|
|
187
|
+
}
|
|
188
|
+
if (!session) fail("host_transport_session_invalid");
|
|
189
|
+
const parent = s.credentials.find(c => c.credentialId === session.credentialId);
|
|
190
|
+
if (parent?.state !== "active" || parent.expiresAt <= timestamp) fail("host_transport_credential_inactive");
|
|
191
|
+
if (session.expiresAt <= timestamp) fail("host_transport_session_expired");
|
|
192
|
+
return { record: session };
|
|
193
|
+
}
|
|
194
|
+
if (command === "ResolveTransport" || command === "VerifyTransport") {
|
|
195
|
+
const c = unique(owned(q),"host_transport_credential_required","host_transport_credential_ambiguous");
|
|
196
|
+
if (c.expiresAt <= timestamp) fail("host_transport_credential_expired");
|
|
197
|
+
const sessions = s.sessions.filter(r => r.credentialId === c.credentialId && sameOwner(r,q));
|
|
198
|
+
if (sessions.length > 1) fail("host_transport_session_ambiguous");
|
|
199
|
+
let selected = sessions[0] || null;
|
|
200
|
+
if (selected && selected.expiresAt <= timestamp) {
|
|
201
|
+
if (command.startsWith("Resolve")) retireSession(s,selected);
|
|
202
|
+
selected = null;
|
|
203
|
+
}
|
|
204
|
+
if (command.startsWith("Resolve")) return { credential:c, session:selected };
|
|
205
|
+
const publicFields = ({credentialId,principalId,hostInstallationId,expiresAt}) => ({credentialId,principalId,hostInstallationId,expiresAt});
|
|
206
|
+
return { credential:publicFields(c), session:selected && {...publicFields(selected),sessionId:selected.sessionId} };
|
|
207
|
+
}
|
|
208
|
+
fail("host_protected_store_command_invalid");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function emptyHost(enrollment = { contract:"hellolynk-device-enrollment-v1", revision:0, value:null }) {
|
|
212
|
+
return { caps:[], credentials:[], sessions:[], retiredSessions:[], codex:null, enrollment };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// On Windows the enrollment file exists independently of the capability file.
|
|
216
|
+
// An unclaimed Mac Host may have only its enrollment in the shared vault. Read
|
|
217
|
+
// its authenticated state without creating, repairing or replacing anything.
|
|
218
|
+
export function verifyDarwinUnclaimedAuthorization(root) {
|
|
219
|
+
return withDarwinVault({ root, readOnly:true }, document => {
|
|
220
|
+
const enrollment = document?.host?.enrollment;
|
|
221
|
+
if (!exactFields(enrollment, ["contract", "revision", "value"])
|
|
222
|
+
|| enrollment.contract !== "hellolynk-device-enrollment-v1"
|
|
223
|
+
|| !Number.isSafeInteger(enrollment.revision) || enrollment.revision < 0
|
|
224
|
+
|| (enrollment.value !== null && (typeof enrollment.value !== "object" || Array.isArray(enrollment.value)))) {
|
|
225
|
+
fail("device_enrollment_store_invalid");
|
|
226
|
+
}
|
|
227
|
+
if (!equal(document, { host:emptyHost(enrollment) })) fail("host_unclaimed_authorization_present");
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function invokeDarwinHost(q) {
|
|
232
|
+
const readOnly = q.verifyOnly || q.command.startsWith("Verify");
|
|
233
|
+
return withDarwinVault({ root:q.root, helperPath:q.helperPath, readOnly, signal:q.signal }, document => {
|
|
234
|
+
const s = document.host || emptyHost();
|
|
235
|
+
if (!readOnly) document.host = s;
|
|
236
|
+
const input = q.stdin ? JSON.parse(q.stdin) : null;
|
|
237
|
+
const old = s.caps.find(c => c.capabilityId === q.capabilityId);
|
|
238
|
+
switch (q.command) {
|
|
239
|
+
case "Status": return {contract:"hellolynk-host-protected-store-v1",protected:true};
|
|
240
|
+
case "LoadDeviceEnrollment": return s.enrollment;
|
|
241
|
+
case "SaveDeviceEnrollment":
|
|
242
|
+
if (s.enrollment.revision !== input.expectedRevision) fail("device_enrollment_revision_conflict");
|
|
243
|
+
s.enrollment.value = input.value;
|
|
244
|
+
s.enrollment.revision++;
|
|
245
|
+
return s.enrollment;
|
|
246
|
+
case "Issue": addCapability(s,input); return {record:input};
|
|
247
|
+
case "Load":
|
|
248
|
+
if (old?.state !== "active") fail("host_capability_inactive");
|
|
249
|
+
return {record:old};
|
|
250
|
+
case "Revoke": return revokeCapability(s,old);
|
|
251
|
+
case "Rotate":
|
|
252
|
+
if (old?.state !== "active") fail("host_capability_inactive");
|
|
253
|
+
addCapability(s,input);
|
|
254
|
+
revokeCapability(s,old);
|
|
255
|
+
return {record:input, replacedCapabilityId:q.capabilityId};
|
|
256
|
+
case "DisconnectLocalClient":
|
|
257
|
+
for (const cap of s.caps.filter(c => c.adapterId === input.client && c.state === "active")) revokeCapability(s,cap);
|
|
258
|
+
if (input.client === "codex") s.codex = null;
|
|
259
|
+
if (input.client === "claude-code") delete s.claudeCode;
|
|
260
|
+
return {disconnected:true,client:input.client};
|
|
261
|
+
}
|
|
262
|
+
const bindingCommand = /^(Resolve|Verify|Save|Remove|Replace|Migrate)(Codex|ClaudeCode)NeutralBinding$/.exec(q.command);
|
|
263
|
+
if (bindingCommand) {
|
|
264
|
+
return neutralBinding(s, bindingCommand[2] === "Codex" ? "codex" : "claude-code", bindingCommand[1], input, q.capabilityId);
|
|
265
|
+
}
|
|
266
|
+
if (q.command.includes("Transport")) return transport(s,q,input);
|
|
267
|
+
fail("host_protected_store_command_unsupported");
|
|
268
|
+
});
|
|
269
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// macOS-only storage. Keychain holds the wrapping key. The SQLite rollback
|
|
2
|
+
// journal and database contain a single authenticated, root-bound ciphertext.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
5
|
+
import { closeSync, constants, lstatSync, mkdirSync, openSync } from "node:fs";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
|
|
10
|
+
export const DARWIN_HELPER = fileURLToPath(new URL("../platform/darwin/hellolynk-keychain-helper.bin", import.meta.url));
|
|
11
|
+
export const DARWIN_VAULT_FILE = "protected-v1.sqlite";
|
|
12
|
+
// Neither the wrapping-key process nor the approval UI needs a caller's
|
|
13
|
+
// shell, language runtime, dynamic-loader overrides or credential environment.
|
|
14
|
+
export function darwinHelperEnvironment() {
|
|
15
|
+
return { HOME: homedir(), PATH: "/usr/bin:/bin", LANG: "en_US.UTF-8" };
|
|
16
|
+
}
|
|
17
|
+
export function vaultError(code) { return Object.assign(new Error(code), { code }); }
|
|
18
|
+
|
|
19
|
+
// Validate before creating any missing components (including in a supplied
|
|
20
|
+
// storeRoot). Never follow links or create children below a linked ancestor.
|
|
21
|
+
export function checkDarwinPath(target, { directory = false, privateMode = false, create = false, missing = false } = {}) {
|
|
22
|
+
if (target === "/" || target !== resolve(target)) throw vaultError("macos_vault_path_invalid");
|
|
23
|
+
const parts = target.slice(1).split("/");
|
|
24
|
+
let current = "/";
|
|
25
|
+
for (const [index, name] of parts.entries()) {
|
|
26
|
+
current = join(current, name);
|
|
27
|
+
const dir = index < parts.length - 1 || directory;
|
|
28
|
+
let st = lstatSync(current, { throwIfNoEntry: false });
|
|
29
|
+
if (!st && create && dir) {
|
|
30
|
+
try { mkdirSync(current, { mode: 0o700 }); }
|
|
31
|
+
catch (error) { if (error.code !== "EEXIST") throw error; }
|
|
32
|
+
st = lstatSync(current);
|
|
33
|
+
}
|
|
34
|
+
if (!st && missing) return null;
|
|
35
|
+
if (!st || st.isSymbolicLink() || (dir ? !st.isDirectory() : !st.isFile())) throw vaultError("macos_vault_path_invalid");
|
|
36
|
+
if (index < parts.length - 1 && ((st.uid !== 0 && st.uid !== process.getuid()) ||
|
|
37
|
+
((st.mode & 0o022) && !(st.uid === 0 && (st.mode & 0o1000))))) {
|
|
38
|
+
throw vaultError("macos_vault_ancestor_invalid");
|
|
39
|
+
}
|
|
40
|
+
if (index === parts.length - 1) {
|
|
41
|
+
if (privateMode && (st.uid !== process.getuid() || (st.mode & 0o077) || (!dir && st.nlink !== 1))) {
|
|
42
|
+
throw vaultError("macos_vault_permissions_invalid");
|
|
43
|
+
}
|
|
44
|
+
return st;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function invokeDarwinHelper(helper, args, signal) {
|
|
50
|
+
const st = checkDarwinPath(helper);
|
|
51
|
+
if ((st.mode & 0o022) || (st.uid !== 0 && st.uid !== process.getuid())) throw vaultError("macos_vault_helper_invalid");
|
|
52
|
+
signal?.throwIfAborted();
|
|
53
|
+
return new Promise((ok, reject) => {
|
|
54
|
+
const child = spawn(helper, args, { stdio: ["ignore", "ignore", "pipe", "pipe"],
|
|
55
|
+
env: darwinHelperEnvironment(), signal });
|
|
56
|
+
const chunks = [];
|
|
57
|
+
let size = 0, stderr = "";
|
|
58
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), 30_000);
|
|
59
|
+
child.stdio[3].on("data", chunk => {
|
|
60
|
+
size += chunk.length;
|
|
61
|
+
if (size <= 32) chunks.push(chunk);
|
|
62
|
+
else child.kill("SIGKILL");
|
|
63
|
+
});
|
|
64
|
+
child.stderr.on("data", chunk => {
|
|
65
|
+
stderr += chunk;
|
|
66
|
+
if (stderr.length > 256) child.kill("SIGKILL");
|
|
67
|
+
});
|
|
68
|
+
child.on("error", error => { clearTimeout(timer); reject(error); });
|
|
69
|
+
child.on("close", code => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
const data = Buffer.concat(chunks);
|
|
72
|
+
chunks.forEach(chunk => chunk.fill(0));
|
|
73
|
+
if (code === 0 && data.length === (args[0] === "key" ? 32 : 0)) return ok(data);
|
|
74
|
+
data.fill(0);
|
|
75
|
+
reject(vaultError(/^macos_[a-z0-9_]{1,100}$/.test(stderr.trim()) ? stderr.trim() : "macos_vault_helper_failed"));
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function openDocument(key, aad, bytes) {
|
|
81
|
+
try {
|
|
82
|
+
const blob = Buffer.from(bytes);
|
|
83
|
+
const cipher = createDecipheriv("aes-256-gcm", key, blob.subarray(0, 12));
|
|
84
|
+
cipher.setAAD(aad);
|
|
85
|
+
cipher.setAuthTag(blob.subarray(12, 28));
|
|
86
|
+
return Buffer.concat([cipher.update(blob.subarray(28)), cipher.final()]);
|
|
87
|
+
} catch { throw vaultError("macos_vault_authentication_failed"); }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sealDocument(key, aad, plain) {
|
|
91
|
+
const nonce = randomBytes(12);
|
|
92
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
93
|
+
cipher.setAAD(aad);
|
|
94
|
+
const body = Buffer.concat([cipher.update(plain), cipher.final()]);
|
|
95
|
+
return Buffer.concat([nonce, cipher.getAuthTag(), body]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The callback is synchronous. No event-loop work or network I/O is performed
|
|
99
|
+
// while SQLite owns the write lock. Concurrent first writers share SecItemAdd's
|
|
100
|
+
// winning key and re-read the committed document after BEGIN IMMEDIATE.
|
|
101
|
+
export async function withDarwinVault({ root, helperPath = DARWIN_HELPER, readOnly = false, allowMissing = false, signal }, operation) {
|
|
102
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
103
|
+
const directory = checkDarwinPath(root, { directory: true, privateMode: true, create: !readOnly, missing: readOnly && allowMissing });
|
|
104
|
+
const path = join(root, DARWIN_VAULT_FILE);
|
|
105
|
+
let stat = directory && checkDarwinPath(path, { privateMode: true, missing: true });
|
|
106
|
+
if (!stat) {
|
|
107
|
+
if (readOnly) {
|
|
108
|
+
if (allowMissing) return operation({});
|
|
109
|
+
throw vaultError("host_protected_store_path_invalid");
|
|
110
|
+
}
|
|
111
|
+
try { closeSync(openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600)); }
|
|
112
|
+
catch (error) { if (error.code !== "EEXIST") throw error; }
|
|
113
|
+
stat = checkDarwinPath(path, { privateMode: true });
|
|
114
|
+
}
|
|
115
|
+
const newFile = stat.size === 0;
|
|
116
|
+
const key = await invokeDarwinHelper(helperPath, ["key", root, !readOnly && newFile ? "create" : "load"], signal);
|
|
117
|
+
let db, plain, encoded;
|
|
118
|
+
try {
|
|
119
|
+
signal?.throwIfAborted();
|
|
120
|
+
checkDarwinPath(path, { privateMode: true });
|
|
121
|
+
checkDarwinPath(path + "-journal", { privateMode: true, missing: true });
|
|
122
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
123
|
+
if (lstatSync(path + suffix, { throwIfNoEntry: false })) throw vaultError("macos_vault_journal_invalid");
|
|
124
|
+
}
|
|
125
|
+
db = new DatabaseSync(path, { readOnly, allowExtension: false });
|
|
126
|
+
db.exec("PRAGMA trusted_schema=OFF; PRAGMA busy_timeout=10000");
|
|
127
|
+
if (db.prepare("PRAGMA journal_mode").get().journal_mode !== "delete") throw vaultError("macos_vault_journal_invalid");
|
|
128
|
+
db.exec(readOnly ? "BEGIN" : "BEGIN IMMEDIATE");
|
|
129
|
+
const schema = db.prepare("SELECT sql FROM sqlite_schema WHERE name = 'vault' AND type = 'table'").get();
|
|
130
|
+
if (!schema) {
|
|
131
|
+
if (!newFile || readOnly) throw vaultError("macos_vault_format_invalid");
|
|
132
|
+
db.exec("CREATE TABLE vault (id INTEGER PRIMARY KEY CHECK(id=1), sealed BLOB NOT NULL)");
|
|
133
|
+
}
|
|
134
|
+
const row = db.prepare("SELECT sealed FROM vault WHERE id=1").get();
|
|
135
|
+
if (schema && !row) throw vaultError("macos_vault_format_invalid");
|
|
136
|
+
const aad = Buffer.from("hellolynk-macos-vault-v1\0" + root);
|
|
137
|
+
plain = row ? openDocument(key, aad, row.sealed) : Buffer.from("{}");
|
|
138
|
+
const state = JSON.parse(plain);
|
|
139
|
+
const result = operation(state);
|
|
140
|
+
encoded = Buffer.from(JSON.stringify(state));
|
|
141
|
+
if (!row || !plain.equals(encoded)) {
|
|
142
|
+
if (readOnly) throw vaultError("host_protected_store_read_only");
|
|
143
|
+
db.prepare("INSERT OR REPLACE INTO vault(id,sealed) VALUES(1,?)").run(sealDocument(key, aad, encoded));
|
|
144
|
+
}
|
|
145
|
+
db.exec("COMMIT");
|
|
146
|
+
return result;
|
|
147
|
+
} finally {
|
|
148
|
+
key.fill(0);
|
|
149
|
+
plain?.fill(0);
|
|
150
|
+
encoded?.fill(0);
|
|
151
|
+
db?.close(); // closes or rolls back a transaction whose callback failed
|
|
152
|
+
}
|
|
153
|
+
}
|