@rightkit/release 0.2.120 → 0.2.122
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/build-release.mjs +6 -2
- package/cli/right-release.mjs +12 -0
- package/committed-source.mjs +200 -0
- package/ownership-approval-command.mjs +39 -0
- package/ownership-approval.mjs +370 -0
- package/ownership-command.mjs +50 -14
- package/ownership-default-policy.json +21 -0
- package/ownership-evidence.mjs +224 -0
- package/ownership-git-inventory.mjs +195 -0
- package/ownership-helper-paths.json +11 -0
- package/ownership-key-authority.mjs +85 -0
- package/ownership-key-command.mjs +530 -0
- package/ownership-key-log.mjs +185 -0
- package/ownership-key-trust.mjs +326 -0
- package/ownership-report-command.mjs +87 -0
- package/ownership-report.mjs +144 -0
- package/ownership-scan-worker.mjs +15 -0
- package/ownership.mjs +504 -197
- package/package.json +4 -1
- package/preflight.mjs +19 -16
- package/publication-gate.mjs +31 -3
- package/publish-cargo.mjs +5 -0
- package/release.mjs +1 -1
- package/rightkit-drift.mjs +112 -0
- package/rightkit-ownership.json +131 -0
- package/rightkit-sync.mjs +150 -0
- package/rightkit-versions.json +111 -26
- package/upload-release.mjs +3 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { accessSync, closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { spawnSync } from "./background-process.mjs";
|
|
7
|
+
import { createKeyEvent, keyFingerprint, verifyKeyLog, verifyKeyRequest } from "./ownership-key-log.mjs";
|
|
8
|
+
import { canonicalRepositoryIdentity, loadTrustedExternal, resolveKeyLogTrust } from "./ownership-approval.mjs";
|
|
9
|
+
import { readCommittedKeyAuthority } from "./ownership-key-authority.mjs";
|
|
10
|
+
|
|
11
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const LOG = ".rightkit/ownership-keys.json";
|
|
13
|
+
const REQUEST_DIR = ".rightkit/key-requests";
|
|
14
|
+
const HEX = /^[a-f0-9]{64}$/;
|
|
15
|
+
const sha = (b) => createHash("sha256").update(b).digest("hex");
|
|
16
|
+
const canonical = (v) => v === null || typeof v !== "object" ? JSON.stringify(v) : Array.isArray(v) ? `[${v.map(canonical).join(",")}]` : `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(",")}}`;
|
|
17
|
+
const platform = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : null;
|
|
18
|
+
const parse = (text, what) => { try { return JSON.parse(text); } catch { throw new Error(`${what} is invalid JSON`); } };
|
|
19
|
+
const json = (v) => `${JSON.stringify(v, null, 2)}\n`;
|
|
20
|
+
let commandTemp;
|
|
21
|
+
const temp = (name) => path.join(commandTemp ?? os.tmpdir(), `.rk-${name}-${process.pid}-${Date.now()}.json`);
|
|
22
|
+
|
|
23
|
+
function repoRoot(app, run) {
|
|
24
|
+
const r = run("git", ["-C", app, "rev-parse", "--show-toplevel"]);
|
|
25
|
+
if (r.status !== 0) throw new Error("--app must resolve to a Git repository");
|
|
26
|
+
return path.resolve(String(r.stdout).trim());
|
|
27
|
+
}
|
|
28
|
+
function git(run, root, args) {
|
|
29
|
+
const r = run("git", ["-C", root, ...args]);
|
|
30
|
+
if (r.status !== 0) throw new Error(String(r.stderr || `git ${args[0]} failed`).trim());
|
|
31
|
+
return String(r.stdout ?? "").trim();
|
|
32
|
+
}
|
|
33
|
+
function readLog(root) {
|
|
34
|
+
const f = path.join(root, LOG);
|
|
35
|
+
if (!existsSync(f)) return { schema: 1, events: [] };
|
|
36
|
+
const st = lstatSync(f);
|
|
37
|
+
if (!st.isFile() || st.isSymbolicLink()) throw new Error("ownership key log must be a regular file");
|
|
38
|
+
return parse(readFileSync(f, "utf8"), "ownership key log");
|
|
39
|
+
}
|
|
40
|
+
function ensureCleanMetadata(root, run, rels) {
|
|
41
|
+
for (const rel of rels) assertSafeMetadataParents(root, rel);
|
|
42
|
+
const result = run("git", ["-C", root, "status", "--porcelain=v1", "--untracked-files=all"]);
|
|
43
|
+
if (result.status !== 0) throw new Error("could not inspect repository metadata status");
|
|
44
|
+
const status = String(result.stdout ?? "");
|
|
45
|
+
for (const rel of rels) {
|
|
46
|
+
if (status.split(/\r?\n/).some((line) => line.slice(3).replaceAll('"', "").endsWith(rel))) throw new Error(`refusing to overwrite dirty metadata: ${rel}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function metadataIsDirty(root, run) {
|
|
50
|
+
const r = run("git", ["-C", root, "status", "--porcelain=v1", "--untracked-files=all"]);
|
|
51
|
+
if (r.status !== 0) throw new Error("could not inspect repository metadata status");
|
|
52
|
+
return String(r.stdout ?? "").split(/\r?\n/).some((line) => {
|
|
53
|
+
const file = line.slice(3).replaceAll('"', "");
|
|
54
|
+
return file === LOG || file.startsWith(`${REQUEST_DIR}/`);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function networkUnavailable(stderr) {
|
|
58
|
+
return stderr?.error?.code === "ETIMEDOUT" || /could not resolve|unable to access|failed to connect|connection timed out|network is unreachable|temporary failure|connection refused|could not resolve host|timed out/i.test(String(stderr?.stderr ?? stderr ?? ""));
|
|
59
|
+
}
|
|
60
|
+
function resolveAuthority(root, run, injected) {
|
|
61
|
+
let authority = injected;
|
|
62
|
+
if (typeof authority === "function") authority = authority(root);
|
|
63
|
+
if (!authority) {
|
|
64
|
+
const origin = run("git", ["-C", root, "remote", "get-url", "origin"]);
|
|
65
|
+
if (origin.status !== 0) throw new Error("ownership key authority requires a configured origin remote");
|
|
66
|
+
authority = { repositoryIdentity: canonicalRepositoryIdentity(String(origin.stdout ?? "").trim()), checkoutPath: path.resolve(root) };
|
|
67
|
+
}
|
|
68
|
+
if (!authority || Object.keys(authority).sort().join("\0") !== ["checkoutPath", "repositoryIdentity"].sort().join("\0")) throw new Error("key authority must contain repositoryIdentity and checkoutPath");
|
|
69
|
+
const repositoryIdentity = canonicalRepositoryIdentity(authority.repositoryIdentity);
|
|
70
|
+
const checkoutPath = path.resolve(authority.checkoutPath);
|
|
71
|
+
if (checkoutPath !== path.resolve(root)) throw new Error("key authority checkoutPath must match this repository checkout");
|
|
72
|
+
return { repositoryIdentity, checkoutPath };
|
|
73
|
+
}
|
|
74
|
+
function sameAuthority(stored, current) {
|
|
75
|
+
if (!stored || Object.keys(stored).sort().join("\0") !== ["checkoutPath", "repositoryIdentity"].sort().join("\0")
|
|
76
|
+
|| stored.repositoryIdentity !== current.repositoryIdentity || typeof stored.checkoutPath !== "string") return false;
|
|
77
|
+
const storedPath = path.resolve(stored.checkoutPath);
|
|
78
|
+
const currentPath = path.resolve(current.checkoutPath);
|
|
79
|
+
return process.platform === "win32" ? storedPath.toLowerCase() === currentPath.toLowerCase() : storedPath === currentPath;
|
|
80
|
+
}
|
|
81
|
+
function runNetworkGit(run, args) {
|
|
82
|
+
return run("git", args, {
|
|
83
|
+
timeout: 30_000,
|
|
84
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_SSH_COMMAND: "ssh -o BatchMode=yes -o ConnectTimeout=30" },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function syncBeforeCeremony(root, run) {
|
|
88
|
+
if (metadataIsDirty(root, run)) throw new Error("ownership metadata has uncommitted changes; commit or discard them before enrolling keys");
|
|
89
|
+
const origin = run("git", ["-C", root, "remote", "get-url", "origin"]);
|
|
90
|
+
if (origin.status !== 0) return { push: false, offline: true, reason: "no origin remote is configured" };
|
|
91
|
+
const branchResult = run("git", ["-C", root, "branch", "--show-current"]);
|
|
92
|
+
const branch = String(branchResult.stdout ?? "").trim();
|
|
93
|
+
const upstreamResult = run("git", ["-C", root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
94
|
+
if (branchResult.status !== 0 || !branch || upstreamResult.status !== 0) throw new Error("current branch must track origin/<same branch> before ownership enrollment");
|
|
95
|
+
const upstream = String(upstreamResult.stdout ?? "").trim();
|
|
96
|
+
if (upstream !== `origin/${branch}`) throw new Error("current branch must track origin/<same branch> before ownership enrollment");
|
|
97
|
+
const fetch = runNetworkGit(run, ["-C", root, "fetch", "--no-tags", "origin", branch]);
|
|
98
|
+
if (fetch.status !== 0) {
|
|
99
|
+
if (networkUnavailable(fetch)) return { push: false, offline: true, reason: "network unavailable" };
|
|
100
|
+
throw new Error("could not fetch origin; check Git access and branch configuration");
|
|
101
|
+
}
|
|
102
|
+
if (metadataIsDirty(root, run)) throw new Error("ownership metadata changed during upstream sync");
|
|
103
|
+
const merge = run("git", ["-C", root, "merge", "--ff-only", "FETCH_HEAD"]);
|
|
104
|
+
if (merge.status !== 0) throw new Error("origin diverged or cannot fast-forward safely; reconcile branch before key enrollment");
|
|
105
|
+
return { push: true, offline: false, branch };
|
|
106
|
+
}
|
|
107
|
+
function assertSafeMetadataParents(root, rel) {
|
|
108
|
+
const candidates = [root];
|
|
109
|
+
let current = root;
|
|
110
|
+
for (const part of path.dirname(rel).split(/[\\/]/).filter(Boolean)) {
|
|
111
|
+
current = path.join(current, part);
|
|
112
|
+
candidates.push(current);
|
|
113
|
+
}
|
|
114
|
+
for (const target of candidates) {
|
|
115
|
+
try {
|
|
116
|
+
const stat = lstatSync(target);
|
|
117
|
+
if (stat.isSymbolicLink() || (process.platform === "win32" && (stat.attributes & 0x400))) throw new Error(`ownership metadata parent is a symlink or reparse point: ${target}`);
|
|
118
|
+
if (!stat.isDirectory()) throw new Error(`ownership metadata parent is not a directory: ${target}`);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (error.code !== "ENOENT") throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function atomicWrite(file, value) {
|
|
125
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
126
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
127
|
+
writeFileSync(tmp, value, { flag: "wx", mode: 0o600 });
|
|
128
|
+
renameSync(tmp, file);
|
|
129
|
+
}
|
|
130
|
+
function installedLegionPath() {
|
|
131
|
+
if (process.platform === "win32") {
|
|
132
|
+
if (!process.env.LOCALAPPDATA) throw new Error("installed Legion minimize hook is active but LOCALAPPDATA is unavailable");
|
|
133
|
+
return path.join(process.env.LOCALAPPDATA, "Orthic Labs", "Legion", "current", "bin", "legion.exe");
|
|
134
|
+
}
|
|
135
|
+
if (process.platform === "darwin") {
|
|
136
|
+
if (!process.env.HOME) throw new Error("installed Legion minimize hook is active but HOME is unavailable");
|
|
137
|
+
return path.join(process.env.HOME, "Library", "Application Support", "Orthic Labs", "Legion", "current", "bin", "legion");
|
|
138
|
+
}
|
|
139
|
+
throw new Error("installed Legion minimize hook is active on unsupported platform");
|
|
140
|
+
}
|
|
141
|
+
function detectMinimizeHook(root, run, deps) {
|
|
142
|
+
const hookPathResult = run("git", ["-C", root, "rev-parse", "--git-path", "hooks/pre-commit"]);
|
|
143
|
+
if (hookPathResult.status !== 0) throw new Error("could not resolve effective pre-commit hook path");
|
|
144
|
+
const hookPath = path.resolve(root, String(hookPathResult.stdout ?? "").trim());
|
|
145
|
+
let source = "";
|
|
146
|
+
try {
|
|
147
|
+
const stat = lstatSync(hookPath);
|
|
148
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("effective pre-commit hook is not a regular file");
|
|
149
|
+
source = readFileSync(hookPath, "utf8");
|
|
150
|
+
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
151
|
+
if (!/minimize\s+commit\s+verify/.test(source) || !/commit-receipt\.json/.test(source)) return null;
|
|
152
|
+
const legion = deps.legionPath ?? installedLegionPath();
|
|
153
|
+
if (!deps.legionPath) {
|
|
154
|
+
try { accessSync(legion, process.platform === "win32" ? undefined : 1); }
|
|
155
|
+
catch { throw new Error(`installed Legion minimize hook is active but stable CLI is unavailable: ${legion}`); }
|
|
156
|
+
}
|
|
157
|
+
return { legion };
|
|
158
|
+
}
|
|
159
|
+
function withIsolatedIndex(root, run, indexPath, args) {
|
|
160
|
+
return run("git", ["-C", root, ...args], { env: { ...process.env, GIT_INDEX_FILE: indexPath } });
|
|
161
|
+
}
|
|
162
|
+
function runMinimizeReceipt(root, run, rel, hook) {
|
|
163
|
+
if (!hook) return null;
|
|
164
|
+
assertSafeMetadataParents(root, ".audit/minimize/commit-receipt.json");
|
|
165
|
+
const directory = path.join(root, ".audit", "minimize");
|
|
166
|
+
mkdirSync(directory, { recursive: true });
|
|
167
|
+
const review = path.join(directory, "commit-review.json");
|
|
168
|
+
const receipt = path.join(directory, "commit-receipt.json");
|
|
169
|
+
const prior = [review, receipt].map((file) => {
|
|
170
|
+
try {
|
|
171
|
+
const stat = lstatSync(file);
|
|
172
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("minimize receipt path must be a regular file");
|
|
173
|
+
return { file, bytes: readFileSync(file), mode: stat.mode & 0o777 };
|
|
174
|
+
} catch (error) { if (error.code === "ENOENT") return { file, absent: true }; throw error; }
|
|
175
|
+
});
|
|
176
|
+
const indexPath = path.join(commandTemp, "minimize-candidate.index");
|
|
177
|
+
try {
|
|
178
|
+
let result = withIsolatedIndex(root, run, indexPath, ["read-tree", "HEAD"]);
|
|
179
|
+
if (result.status !== 0) throw new Error("could not prepare isolated metadata commit index");
|
|
180
|
+
result = withIsolatedIndex(root, run, indexPath, ["add", "--", rel]);
|
|
181
|
+
if (result.status !== 0) throw new Error("could not add ownership metadata to isolated commit index");
|
|
182
|
+
const env = { ...process.env, GIT_INDEX_FILE: indexPath };
|
|
183
|
+
for (const args of [["minimize", "commit", "init-review", review], ["minimize", "commit", "receipt", review, receipt]]) {
|
|
184
|
+
result = run(hook.legion, args, { cwd: root, env });
|
|
185
|
+
if (result.status !== 0) throw new Error(String(result.stderr || "Legion could not prepare required minimize commit receipt").trim());
|
|
186
|
+
}
|
|
187
|
+
hook.prior = prior;
|
|
188
|
+
return indexPath;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
restoreGeneratedFiles(prior);
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function restoreGeneratedFiles(prior) {
|
|
195
|
+
for (const item of prior) {
|
|
196
|
+
if (item.absent) { if (existsSync(item.file)) unlinkSync(item.file); }
|
|
197
|
+
else writeFileSync(item.file, item.bytes, { mode: item.mode });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function saveAndCommit(root, run, rel, value, expectedHead, transport, print, hook) {
|
|
201
|
+
ensureCleanMetadata(root, run, [rel]);
|
|
202
|
+
if (expectedHead && git(run, root, ["rev-parse", "HEAD"]) !== expectedHead) throw new Error("repository HEAD changed during ownership ceremony");
|
|
203
|
+
atomicWrite(path.join(root, rel), value);
|
|
204
|
+
const head = git(run, root, ["rev-parse", "HEAD"]);
|
|
205
|
+
if (expectedHead && head !== expectedHead) throw new Error("repository HEAD changed before metadata commit");
|
|
206
|
+
const indexPath = runMinimizeReceipt(root, run, rel, hook);
|
|
207
|
+
const env = indexPath ? { env: { ...process.env, GIT_INDEX_FILE: indexPath } } : {};
|
|
208
|
+
if (!indexPath) git(run, root, ["add", "--", rel]);
|
|
209
|
+
let commit;
|
|
210
|
+
try { commit = run("git", ["-C", root, "commit", "--only", "-m", `right-release: update ${rel}`, "--", rel], env); }
|
|
211
|
+
finally { if (hook) restoreGeneratedFiles(hook.prior ?? []); }
|
|
212
|
+
if (commit.status !== 0) throw new Error(String(commit.stderr || "exact-path metadata commit failed").trim());
|
|
213
|
+
if (indexPath) {
|
|
214
|
+
const reset = run("git", ["-C", root, "reset", "--quiet", "HEAD", "--", rel]);
|
|
215
|
+
if (reset.status !== 0) throw new Error("metadata commit succeeded but its generated path could not be reconciled in the repository index");
|
|
216
|
+
}
|
|
217
|
+
if (git(run, root, ["rev-parse", "HEAD"]) === head) throw new Error("metadata commit did not advance repository HEAD");
|
|
218
|
+
if (transport?.offline) print(transport.reason === "network unavailable"
|
|
219
|
+
? "Ownership metadata committed locally; queued offline (network unavailable)."
|
|
220
|
+
: `Ownership metadata committed locally (${transport.reason}; local-only).`);
|
|
221
|
+
else if (transport?.push) {
|
|
222
|
+
const push = runNetworkGit(run, ["-C", root, "push", "origin", transport.branch]);
|
|
223
|
+
if (push.status !== 0) {
|
|
224
|
+
if (networkUnavailable(push)) print("Ownership metadata committed locally; push queued offline (network unavailable).");
|
|
225
|
+
else print("Ownership metadata committed locally; push was rejected and requires Git reconciliation.");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function helperFor(run) {
|
|
230
|
+
if (!platform) throw new Error(`ownership key management unsupported platform: ${process.platform}`);
|
|
231
|
+
const config = parse(readFileSync(path.join(here, "ownership-helper-paths.json"), "utf8"), "ownership helper config");
|
|
232
|
+
const item = config?.schema === 1 ? config[platform] : null;
|
|
233
|
+
if (!item || !path.isAbsolute(item.path) || !HEX.test(item.sha256 ?? "")) throw new Error(`ownership helper config lacks a valid ${platform} entry`);
|
|
234
|
+
const bytes = readFileSync(item.path);
|
|
235
|
+
if (sha(bytes) !== item.sha256) throw new Error("ownership native helper hash does not match pinned config");
|
|
236
|
+
return item.path;
|
|
237
|
+
}
|
|
238
|
+
function invokeHelper(run, helper, args) {
|
|
239
|
+
const r = run(helper, args);
|
|
240
|
+
if (r.status !== 0) throw new Error(String(r.stderr || "native ownership operation cancelled or failed").trim());
|
|
241
|
+
return String(r.stdout ?? "");
|
|
242
|
+
}
|
|
243
|
+
function helperFile(run, helper, args, outPath, what) {
|
|
244
|
+
invokeHelper(run, helper, args);
|
|
245
|
+
return parse(readFileSync(outPath, "utf8"), what);
|
|
246
|
+
}
|
|
247
|
+
function readRequestFile(file) {
|
|
248
|
+
const stat = lstatSync(file);
|
|
249
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size < 2 || stat.size > 2 * 1024 * 1024) throw new Error("key request must be a bounded regular file");
|
|
250
|
+
return parse(readFileSync(file, "utf8"), "key request");
|
|
251
|
+
}
|
|
252
|
+
function validateNative(out, expectedHost = platform) {
|
|
253
|
+
if (!out || out.host !== expectedHost || typeof out.keyId !== "string" || typeof out.publicKeyPem !== "string") throw new Error("native helper returned malformed key data");
|
|
254
|
+
const fingerprint = keyFingerprint(out.publicKeyPem);
|
|
255
|
+
if (out.keyId !== `${expectedHost === "windows" ? "win" : "mac"}-${fingerprint}`) throw new Error("native helper key id does not match its public key");
|
|
256
|
+
return { keyId: fingerprint, publicKeyPem: out.publicKeyPem, platform: expectedHost };
|
|
257
|
+
}
|
|
258
|
+
function elevate(run, requestPath, digest) {
|
|
259
|
+
const child = path.join(here, "ownership-key-trust.mjs");
|
|
260
|
+
const args = [child, "--request", requestPath, "--sha256", digest];
|
|
261
|
+
if (process.platform === "win32") {
|
|
262
|
+
const invocation = buildWindowsElevationInvocation(process.execPath, child, requestPath, digest);
|
|
263
|
+
const r = run(invocation.parentExecutable, invocation.parentArgs);
|
|
264
|
+
if (r.status !== 0) throw new Error("elevated trust update failed or was cancelled");
|
|
265
|
+
} else {
|
|
266
|
+
let r;
|
|
267
|
+
try { r = run("/usr/bin/sudo", ["-k", process.execPath, ...args], { stdio: "inherit" }); }
|
|
268
|
+
finally { try { run("/usr/bin/sudo", ["-K"], { stdio: "inherit" }); } catch {} }
|
|
269
|
+
if (r.status !== 0) throw new Error("elevated trust update failed or was cancelled");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function buildWindowsElevationInvocation(nodeExe, trustScript, requestPath, digest) {
|
|
273
|
+
const powershell = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
|
|
274
|
+
const quote = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
275
|
+
const childScript = `& ${[nodeExe, trustScript, "--request", requestPath, "--sha256", digest].map(quote).join(" ")}; exit $LASTEXITCODE`;
|
|
276
|
+
const childEncoded = Buffer.from(childScript, "utf16le").toString("base64");
|
|
277
|
+
const parentScript = `$ErrorActionPreference='Stop'; try { $p=Start-Process -FilePath ${quote(powershell)} -ArgumentList ${quote(`-NoProfile -EncodedCommand ${childEncoded}`)} -Verb RunAs -WindowStyle Hidden -Wait -PassThru; exit $p.ExitCode } catch { exit 1 }`;
|
|
278
|
+
return { parentExecutable: powershell, parentArgs: ["-NoProfile", "-EncodedCommand", Buffer.from(parentScript, "utf16le").toString("base64")], childScript };
|
|
279
|
+
}
|
|
280
|
+
export const __testOnly = Object.freeze({ buildWindowsElevationInvocation, assertSafeMetadataParents, detectMinimizeHook });
|
|
281
|
+
function trustState() { return loadTrustedExternal(); }
|
|
282
|
+
function currentLogTrust(root, log, trust = trustState(), authority, injected = false) {
|
|
283
|
+
if (!trust.trust?.rootFingerprint) throw new Error("protected root trust is not enrolled on this host");
|
|
284
|
+
if (!sameAuthority(trust.trust.keyAuthority, authority)) throw new Error("protected key authority is bound to a different repository checkout");
|
|
285
|
+
if (!trust.trust.keyAuthority) throw new Error("protected key trust has no repository authority");
|
|
286
|
+
if (!injected) {
|
|
287
|
+
const committed = readCommittedKeyAuthority(trust.trust);
|
|
288
|
+
if (!committed.ok || committed.authority.repositoryIdentity !== authority.repositoryIdentity
|
|
289
|
+
|| !sameAuthority(trust.trust.keyAuthority, authority) || !Buffer.from(committed.bytes).equals(Buffer.from(json(log)))) {
|
|
290
|
+
throw new Error(`committed ownership key authority rejected: ${(committed.problems ?? []).join("; ") || "local log differs from committed bytes"}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const resolved = resolveKeyLogTrust(trust.trust, Buffer.from(json(log)));
|
|
294
|
+
if (!resolved.ok) throw new Error(`ownership key log rejected: ${resolved.problems.join("; ")}`);
|
|
295
|
+
return resolved;
|
|
296
|
+
}
|
|
297
|
+
function makeTrustRequest(log, rootFingerprint, baseline, authority) {
|
|
298
|
+
const file = temp("trust-request");
|
|
299
|
+
const bytes = Buffer.from(json({ schema: 1, log, rootFingerprint, baseline, authority }));
|
|
300
|
+
writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
|
|
301
|
+
return { file, digest: sha(bytes) };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function main(args, deps = {}) {
|
|
305
|
+
const run = deps.runner ?? spawnSync;
|
|
306
|
+
const print = deps.print ?? console.log;
|
|
307
|
+
const app = path.resolve(flag(args, "--app") ?? process.cwd());
|
|
308
|
+
const root = deps.root ?? repoRoot(app, run);
|
|
309
|
+
try {
|
|
310
|
+
if (!platform) throw new Error(`ownership key management unsupported platform: ${process.platform}`);
|
|
311
|
+
const helper = deps.helper ?? helperFor(run);
|
|
312
|
+
const lockPath = deps.lockPath ?? path.join(os.tmpdir(), "rightkit-key-command.lock");
|
|
313
|
+
let lockFd;
|
|
314
|
+
try { lockFd = openSync(lockPath, "wx", 0o600); } catch { throw new Error("another ownership key command is active for this repository"); }
|
|
315
|
+
try {
|
|
316
|
+
commandTemp = mkdtempSync(path.join(os.tmpdir(), "rightkit-key-command-"));
|
|
317
|
+
const authority = resolveAuthority(root, run, deps.authority);
|
|
318
|
+
const hook = detectMinimizeHook(root, run, deps);
|
|
319
|
+
const transport = syncBeforeCeremony(root, run);
|
|
320
|
+
const headSnapshot = git(run, root, ["rev-parse", "HEAD"]);
|
|
321
|
+
const verb = args[0];
|
|
322
|
+
if (verb === "enroll") return await enroll(args, { ...deps, run, print, root, helper, headSnapshot, transport, authority, hook });
|
|
323
|
+
if (verb === "approve-key") return await approve(args, { ...deps, run, print, root, helper, headSnapshot, transport, authority, hook });
|
|
324
|
+
if (verb === "revoke-key") return await revoke(args, { ...deps, run, print, root, helper, headSnapshot, transport, authority, hook });
|
|
325
|
+
throw new Error("expected enroll, approve-key, or revoke-key");
|
|
326
|
+
} finally {
|
|
327
|
+
closeSync(lockFd);
|
|
328
|
+
rmSync(lockPath, { force: true });
|
|
329
|
+
if (commandTemp) rmSync(commandTemp, { recursive: true, force: true });
|
|
330
|
+
commandTemp = undefined;
|
|
331
|
+
}
|
|
332
|
+
} catch (error) { print(`[right-release] ownership ${args[0] ?? "keys"} failed: ${error.message}`); return 1; }
|
|
333
|
+
}
|
|
334
|
+
function flag(args, name) { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; }
|
|
335
|
+
|
|
336
|
+
async function enroll(args, d) {
|
|
337
|
+
const { run, print, root, helper } = d;
|
|
338
|
+
const name = flag(args, "--name") ?? "";
|
|
339
|
+
assertSafeMetadataParents(root, LOG);
|
|
340
|
+
assertSafeMetadataParents(root, `${REQUEST_DIR}/pending.json`);
|
|
341
|
+
const log = readLog(root);
|
|
342
|
+
if (log.events.length === 0) {
|
|
343
|
+
if (!args.includes("--root")) throw new Error("empty key log requires explicit --root");
|
|
344
|
+
ensureCleanMetadata(root, run, [LOG]);
|
|
345
|
+
const baseline = d.baseline ?? parse(readFileSync(path.join(here, "ownership-default-policy.json"), "utf8"), "ownership default policy");
|
|
346
|
+
const out = temp("genesis-signed");
|
|
347
|
+
const nativeEvent = helperFile(run, helper, ["--enroll-root", "--name", name || "Root device", "--out", out], out, "signed genesis event");
|
|
348
|
+
const record = nativeEvent?.payload?.subject;
|
|
349
|
+
if (!record || record.platform !== platform || record.deviceName !== (name || "Root device") || record.keyId !== keyFingerprint(record.publicKeyPem)) throw new Error("native helper returned malformed signed root event");
|
|
350
|
+
try {
|
|
351
|
+
// Root enrollment uses one native authorization call; helper returns its signed event beside key data.
|
|
352
|
+
const next = { schema: 1, events: [nativeEvent] };
|
|
353
|
+
const checked = verifyKeyLog(next, { rootFingerprint: record.keyId });
|
|
354
|
+
if (!checked.ok) throw new Error(`native root signature invalid: ${checked.problems.join("; ")}`);
|
|
355
|
+
saveAndCommit(root, run, LOG, json(next), d.headSnapshot, d.transport, print, d.hook);
|
|
356
|
+
const request = makeTrustRequest(next, record.keyId, baseline, d.authority);
|
|
357
|
+
try { (d.elevate ?? elevate)(run, request.file, request.digest); } finally { await import("node:fs").then(({ unlinkSync }) => unlinkSync(request.file)).catch(() => {}); }
|
|
358
|
+
postVerify(root, next, d);
|
|
359
|
+
print(`Ownership root enrolled: ${record.keyId}; active keys: 1`);
|
|
360
|
+
return 0;
|
|
361
|
+
} finally { await import("node:fs").then(({ unlinkSync }) => unlinkSync(out)).catch(() => {}); }
|
|
362
|
+
}
|
|
363
|
+
if (args.includes("--root")) return resumeRootEnrollment(log, d);
|
|
364
|
+
const pinned = flag(args, "--root-fingerprint");
|
|
365
|
+
if (!HEX.test(pinned ?? "")) throw new Error("additional enrollment requires --root-fingerprint");
|
|
366
|
+
const checked = verifyKeyLog(log, { rootFingerprint: pinned });
|
|
367
|
+
if (!checked.ok) throw new Error(`local key log rejected: ${checked.problems.join("; ")}`);
|
|
368
|
+
const hostTrust = (d.trustLoader ?? trustState)();
|
|
369
|
+
if (hostTrust?.trust?.keyAuthority && !sameAuthority(hostTrust.trust.keyAuthority, d.authority)) throw new Error("protected key authority is bound to a different repository checkout");
|
|
370
|
+
if (hostTrust?.trust?.rootFingerprint) currentLogTrust(root, log, hostTrust, d.authority, Boolean(d.resolveTrust));
|
|
371
|
+
const publicPath = temp("public");
|
|
372
|
+
let publicRecord;
|
|
373
|
+
try {
|
|
374
|
+
publicRecord = validateNative(helperFile(run, helper, ["--public-key", "--out", publicPath], publicPath, "native public key"));
|
|
375
|
+
} catch { /* Fresh devices may not have a public key; pending requests are checked before enrollment resumes. */ }
|
|
376
|
+
if (!publicRecord) {
|
|
377
|
+
const trust = (d.trustLoader ?? trustState)();
|
|
378
|
+
const requestDir = path.join(root, REQUEST_DIR);
|
|
379
|
+
if (!trust?.trust && existsSync(requestDir)) {
|
|
380
|
+
for (const name of readdirSync(requestDir)) {
|
|
381
|
+
if (!name.endsWith(".json")) continue;
|
|
382
|
+
const prior = readRequestFile(path.join(requestDir, name));
|
|
383
|
+
if (verifyKeyRequest(prior, { rootFingerprint: pinned }).ok) throw new Error("pending request exists but native public-key discovery failed; refusing to create a second native key");
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (publicRecord) {
|
|
388
|
+
const pendingRel = `${REQUEST_DIR}/${publicRecord.keyId}.json`;
|
|
389
|
+
const pendingPath = path.join(root, pendingRel);
|
|
390
|
+
if (existsSync(pendingPath)) {
|
|
391
|
+
const request = readRequestFile(pendingPath);
|
|
392
|
+
const valid = verifyKeyRequest(request, { rootFingerprint: pinned });
|
|
393
|
+
if (!valid.ok || request.key.keyId !== publicRecord.keyId || request.key.platform !== publicRecord.platform) throw new Error("existing enrollment request does not match this native key and root pin");
|
|
394
|
+
ensureCleanMetadata(root, run, [LOG, pendingRel]);
|
|
395
|
+
const trust = (d.trustLoader ?? trustState)();
|
|
396
|
+
if (trust?.trust) throw new Error("matching request already exists and protected trust is present; refusing a second enrollment");
|
|
397
|
+
const baseline = d.baseline ?? parse(readFileSync(path.join(here, "ownership-default-policy.json"), "utf8"), "ownership default policy");
|
|
398
|
+
await installCheckpoint(log, pinned, baseline, d);
|
|
399
|
+
postVerify(root, log, d);
|
|
400
|
+
print(`Enrollment recovery completed for ${publicRecord.keyId}; request ${pendingRel} remains pending approval`);
|
|
401
|
+
return 0;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const outPath = temp("enroll");
|
|
405
|
+
const native = helperFile(run, helper, ["--enroll", "--out", outPath], outPath, "native enrollment key");
|
|
406
|
+
const record = { ...validateNative(native), deviceName: name || `${platform} device` };
|
|
407
|
+
if (checked.allKeys.some((key) => key.keyId === record.keyId)) throw new Error("this native key already appears in the key log");
|
|
408
|
+
const request = { schema: 1, type: "rightkit.ownership-key-request.v1", rootFingerprint: pinned, key: record };
|
|
409
|
+
const verified = verifyKeyRequest(request, { rootFingerprint: pinned });
|
|
410
|
+
if (!verified.ok) throw new Error(verified.problems.join("; "));
|
|
411
|
+
const reqRel = `${REQUEST_DIR}/${record.keyId}.json`;
|
|
412
|
+
ensureCleanMetadata(root, run, [reqRel]);
|
|
413
|
+
saveAndCommit(root, run, reqRel, json(request), d.headSnapshot, d.transport, print, d.hook);
|
|
414
|
+
const baseline = d.baseline ?? parse(readFileSync(path.join(here, "ownership-default-policy.json"), "utf8"), "ownership default policy");
|
|
415
|
+
const trustReq = makeTrustRequest(log, pinned, baseline, d.authority);
|
|
416
|
+
try { (d.elevate ?? elevate)(run, trustReq.file, trustReq.digest); }
|
|
417
|
+
finally { await import("node:fs").then(({ unlinkSync }) => unlinkSync(trustReq.file)).catch(() => {}); }
|
|
418
|
+
postVerify(root, log, d);
|
|
419
|
+
print(`Enrollment request created: ${record.keyId}; active keys: ${checked.keys.length}; request ${reqRel}; approve on active host`);
|
|
420
|
+
return 0;
|
|
421
|
+
}
|
|
422
|
+
async function resumeRootEnrollment(log, d) {
|
|
423
|
+
const { run, print, root, helper } = d;
|
|
424
|
+
if (!log.events.length) throw new Error("empty key log requires explicit --root initialization");
|
|
425
|
+
const trust = (d.trustLoader ?? trustState)();
|
|
426
|
+
if (trust?.trust) throw new Error("--root cannot replace an existing protected trust root");
|
|
427
|
+
ensureCleanMetadata(root, run, [LOG]);
|
|
428
|
+
const publicPath = temp("public");
|
|
429
|
+
const publicKey = validateNative(helperFile(run, helper, ["--public-key", "--out", publicPath], publicPath, "native public key"));
|
|
430
|
+
const pin = log.events[0]?.payload?.rootFingerprint;
|
|
431
|
+
if (publicKey.keyId !== pin || publicKey.platform !== log.events[0]?.payload?.subject?.platform) throw new Error("existing root log does not match this host's unauthenticated native public key");
|
|
432
|
+
const state = verifyKeyLog(log, { rootFingerprint: pin });
|
|
433
|
+
if (!state.ok || state.keys[0]?.keyId !== pin) throw new Error(`existing root log is invalid: ${state.problems.join("; ")}`);
|
|
434
|
+
const baseline = d.baseline ?? parse(readFileSync(path.join(here, "ownership-default-policy.json"), "utf8"), "ownership default policy");
|
|
435
|
+
await installCheckpoint(log, pin, baseline, d);
|
|
436
|
+
postVerify(root, log, d);
|
|
437
|
+
print(`Ownership root enrollment recovery completed: ${pin}; active keys: ${state.keys.length}`);
|
|
438
|
+
return 0;
|
|
439
|
+
}
|
|
440
|
+
async function installCheckpoint(log, rootFingerprint, baseline, d) {
|
|
441
|
+
const request = makeTrustRequest(log, rootFingerprint, baseline, d.authority);
|
|
442
|
+
try { await (d.elevate ?? elevate)(d.run, request.file, request.digest); }
|
|
443
|
+
finally { await import("node:fs").then(({ unlinkSync }) => unlinkSync(request.file)).catch(() => {}); }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function approve(args, d) {
|
|
447
|
+
const { run, print, root, helper } = d;
|
|
448
|
+
assertSafeMetadataParents(root, LOG);
|
|
449
|
+
assertSafeMetadataParents(root, `${REQUEST_DIR}/pending.json`);
|
|
450
|
+
const target = args[1];
|
|
451
|
+
if (!target) throw new Error("approve-key requires a fingerprint or request JSON path");
|
|
452
|
+
const log = readLog(root);
|
|
453
|
+
const pin = log.events[0]?.payload?.rootFingerprint;
|
|
454
|
+
if (!pin) throw new Error("no root key log exists");
|
|
455
|
+
currentLogTrust(root, log, d.trustLoader?.() ?? trustState(), d.authority, Boolean(d.resolveTrust));
|
|
456
|
+
let request;
|
|
457
|
+
if (HEX.test(target)) {
|
|
458
|
+
const file = path.join(root, REQUEST_DIR, `${target}.json`);
|
|
459
|
+
request = readRequestFile(file);
|
|
460
|
+
} else {
|
|
461
|
+
const file = path.resolve(root, target);
|
|
462
|
+
if (!file.startsWith(`${path.join(root, REQUEST_DIR)}${path.sep}`)) throw new Error("request path must be inside .rightkit/key-requests");
|
|
463
|
+
request = readRequestFile(file);
|
|
464
|
+
}
|
|
465
|
+
const valid = verifyKeyRequest(request, { rootFingerprint: pin });
|
|
466
|
+
if (!valid.ok) throw new Error(valid.problems.join("; "));
|
|
467
|
+
if (verifyKeyLog(log, { rootFingerprint: pin }).allKeys.some((key) => key.keyId === request.key.keyId)) throw new Error("requested key already appears in log");
|
|
468
|
+
const signerPath = temp("public");
|
|
469
|
+
const signerData = helperFile(run, helper, ["--public-key", "--out", signerPath], signerPath, "native public key");
|
|
470
|
+
const signer = validateNative(signerData);
|
|
471
|
+
const state = verifyKeyLog(log, { rootFingerprint: pin });
|
|
472
|
+
if (!state.keys.some((key) => key.keyId === signer.keyId)) throw new Error("this host's key is not active in protected key log");
|
|
473
|
+
const next = await (d.signEvent ? d.signEvent(log, "add", request.key, signer) : signWithHelper(run, helper, log, "add", request.key, signer));
|
|
474
|
+
const rel = LOG;
|
|
475
|
+
saveAndCommit(root, run, rel, json(next), d.headSnapshot, d.transport, print, d.hook);
|
|
476
|
+
await updateCheckpoint(next, pin, d);
|
|
477
|
+
postVerify(root, next, d);
|
|
478
|
+
print(`Ownership key approved: ${request.key.keyId}; active keys: ${verifyKeyLog(next, { rootFingerprint: pin }).keys.length}`);
|
|
479
|
+
return 0;
|
|
480
|
+
}
|
|
481
|
+
async function revoke(args, d) {
|
|
482
|
+
const { run, print, root, helper } = d;
|
|
483
|
+
assertSafeMetadataParents(root, LOG);
|
|
484
|
+
const keyId = args[1];
|
|
485
|
+
if (!HEX.test(keyId ?? "")) throw new Error("revoke-key requires a lowercase SHA-256 fingerprint");
|
|
486
|
+
const log = readLog(root), pin = log.events[0]?.payload?.rootFingerprint;
|
|
487
|
+
if (!pin) throw new Error("no root key log exists");
|
|
488
|
+
currentLogTrust(root, log, d.trustLoader?.() ?? trustState(), d.authority, Boolean(d.resolveTrust));
|
|
489
|
+
const signerPath = temp("public");
|
|
490
|
+
const signerData = helperFile(run, helper, ["--public-key", "--out", signerPath], signerPath, "native public key");
|
|
491
|
+
const signer = validateNative(signerData);
|
|
492
|
+
const state = verifyKeyLog(log, { rootFingerprint: pin });
|
|
493
|
+
if (!state.keys.some((key) => key.keyId === signer.keyId)) throw new Error("this host's key is not active in protected key log");
|
|
494
|
+
const next = await (d.signEvent ? d.signEvent(log, "revoke", { keyId }, signer) : signWithHelper(run, helper, log, "revoke", { keyId }, signer));
|
|
495
|
+
saveAndCommit(root, run, LOG, json(next), d.headSnapshot, d.transport, print, d.hook);
|
|
496
|
+
await updateCheckpoint(next, pin, d);
|
|
497
|
+
postVerify(root, next, d);
|
|
498
|
+
print(`Ownership key revoked: ${keyId}; active keys: ${verifyKeyLog(next, { rootFingerprint: pin }).keys.length}`);
|
|
499
|
+
return 0;
|
|
500
|
+
}
|
|
501
|
+
async function signWithHelper(run, helper, log, operation, subject, signer) {
|
|
502
|
+
const draft = createKeyEvent(log, { operation, subject, signerKeyId: signer.keyId });
|
|
503
|
+
const input = temp("draft"), output = temp("draft-signed");
|
|
504
|
+
writeFileSync(input, json(draft), { flag: "wx", mode: 0o600 });
|
|
505
|
+
try {
|
|
506
|
+
const event = helperFile(run, helper, ["--key-operation", input, "--out", output], output, "signed key event");
|
|
507
|
+
if (canonical(event?.payload) !== canonical(draft.payload)) throw new Error("native signer changed key event payload");
|
|
508
|
+
const next = { schema: 1, events: [...log.events, event] };
|
|
509
|
+
const result = verifyKeyLog(next, { rootFingerprint: draft.payload.rootFingerprint });
|
|
510
|
+
if (!result.ok) throw new Error(`signed key event rejected: ${result.problems.join("; ")}`);
|
|
511
|
+
return next;
|
|
512
|
+
} finally { await import("node:fs").then(({ unlinkSync }) => unlinkSync(input)).catch(() => {}); await import("node:fs").then(({ unlinkSync }) => unlinkSync(output)).catch(() => {}); }
|
|
513
|
+
}
|
|
514
|
+
async function updateCheckpoint(log, rootFingerprint, d) {
|
|
515
|
+
const request = d.trustRequest ? d.trustRequest(log, rootFingerprint) : (() => {
|
|
516
|
+
const file = temp("trust");
|
|
517
|
+
const bytes = Buffer.from(json({ schema: 1, log, rootFingerprint, baseline: null, authority: d.authority })); writeFileSync(file, bytes, { flag: "wx", mode: 0o600 }); return { file, digest: sha(bytes) };
|
|
518
|
+
})();
|
|
519
|
+
try { await (d.elevate ?? elevate)(d.run, request.file, request.digest); }
|
|
520
|
+
finally { await import("node:fs").then(({ unlinkSync }) => unlinkSync(request.file)).catch(() => {}); }
|
|
521
|
+
}
|
|
522
|
+
function postVerify(root, log, d) {
|
|
523
|
+
const state = (d.trustLoader ?? trustState)();
|
|
524
|
+
if (!sameAuthority(state.trust?.keyAuthority, d.authority)) throw new Error("installed key authority does not match this repository checkout");
|
|
525
|
+
const committed = d.resolveTrust ? { ok: true, bytes: Buffer.from(json(log)), authority: { repositoryIdentity: d.authority.repositoryIdentity }, problems: [] } : readCommittedKeyAuthority(state.trust);
|
|
526
|
+
if (!committed.ok || committed.authority.repositoryIdentity !== d.authority.repositoryIdentity
|
|
527
|
+
|| !Buffer.from(committed.bytes).equals(Buffer.from(json(log)))) throw new Error(`committed ownership key authority rejected: ${(committed.problems ?? []).join("; ") || "committed log differs from installed log"}`);
|
|
528
|
+
const checked = (d.resolveTrust ?? resolveKeyLogTrust)(state.trust, committed.bytes);
|
|
529
|
+
if (!checked.ok) throw new Error(`post-install verification failed: ${checked.problems.join("; ")}`);
|
|
530
|
+
}
|