@rightkit/release 0.2.121 → 0.2.123
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 +10 -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 +2 -1
- 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,185 @@
|
|
|
1
|
+
import { constants, createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const HEX = /^[a-f0-9]{64}$/;
|
|
4
|
+
const DOMAIN = "rightkit.ownership-key-log.v1";
|
|
5
|
+
const REQUEST_TYPE = "rightkit.ownership-key-request.v1";
|
|
6
|
+
const PLATFORMS = new Set(["windows", "macos"]);
|
|
7
|
+
const OPERATIONS = new Set(["root", "add", "revoke"]);
|
|
8
|
+
const exact = (value, keys) => value && typeof value === "object" && !Array.isArray(value)
|
|
9
|
+
&& Object.keys(value).sort().join("\0") === [...keys].sort().join("\0");
|
|
10
|
+
const canonicalJson = (value) => {
|
|
11
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
12
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
13
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
|
14
|
+
};
|
|
15
|
+
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
16
|
+
const validText = (s, max) => typeof s === "string" && s.trim().length > 0 && s.length <= max && !/[\u0000-\u001f\u007f]/.test(s);
|
|
17
|
+
|
|
18
|
+
export function keyFingerprint(pem) {
|
|
19
|
+
const key = createPublicKey(pem);
|
|
20
|
+
const ec = key.asymmetricKeyType === "ec" && key.asymmetricKeyDetails?.namedCurve === "prime256v1";
|
|
21
|
+
const rsa = key.asymmetricKeyType === "rsa" && key.asymmetricKeyDetails?.modulusLength === 2048 && key.asymmetricKeyDetails?.publicExponent === 65537n;
|
|
22
|
+
if (!ec && !rsa) throw new Error("key must be P-256 ECDSA or RSA-2048 with exponent 65537");
|
|
23
|
+
return digest(key.export({ type: "spki", format: "der" }));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function verifyOwnershipSignature(publicKeyPem, payloadBytes, signatureBytes) {
|
|
27
|
+
try {
|
|
28
|
+
const key = createPublicKey(publicKeyPem);
|
|
29
|
+
const message = Buffer.from(payloadBytes);
|
|
30
|
+
const signature = Buffer.from(signatureBytes);
|
|
31
|
+
if (!Buffer.isBuffer(payloadBytes) && !(payloadBytes instanceof Uint8Array)) return false;
|
|
32
|
+
if (!Buffer.isBuffer(signatureBytes) && !(signatureBytes instanceof Uint8Array)) return false;
|
|
33
|
+
if (message.length > 1_000_000 || signature.length > 256) return false;
|
|
34
|
+
keyFingerprint(publicKeyPem);
|
|
35
|
+
if (key.asymmetricKeyType === "rsa") {
|
|
36
|
+
if (signature.length !== 256) return false;
|
|
37
|
+
return cryptoVerify("sha256", message, { key, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 }, signature)
|
|
38
|
+
|| cryptoVerify("sha256", message, { key, padding: constants.RSA_PKCS1_PADDING }, signature);
|
|
39
|
+
}
|
|
40
|
+
return cryptoVerify("sha256", message, key, signature);
|
|
41
|
+
} catch { return false; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function validateKeyRecord(record) {
|
|
45
|
+
const problems = [];
|
|
46
|
+
if (!exact(record, ["keyId", "publicKeyPem", "platform", "deviceName"])) return ["key record fields are malformed"];
|
|
47
|
+
if (!HEX.test(record.keyId ?? "")) problems.push("key id must be a lowercase SHA-256 fingerprint");
|
|
48
|
+
if (!PLATFORMS.has(record.platform)) problems.push("key platform is invalid");
|
|
49
|
+
if (!validText(record.deviceName, 128)) problems.push("device name must be nonempty and at most 128 characters");
|
|
50
|
+
if (typeof record.publicKeyPem !== "string" || record.publicKeyPem.length > 4096
|
|
51
|
+
|| !/^-----BEGIN PUBLIC KEY-----\r?\n[\s\S]+\r?\n-----END PUBLIC KEY-----\r?\n?$/.test(record.publicKeyPem)) problems.push("public key PEM must be a bounded SPKI public key");
|
|
52
|
+
else {
|
|
53
|
+
try {
|
|
54
|
+
const key = createPublicKey(record.publicKeyPem);
|
|
55
|
+
if (keyFingerprint(record.publicKeyPem) !== record.keyId) problems.push("key id does not match public key");
|
|
56
|
+
if (key.asymmetricKeyType === "rsa" && record.platform !== "windows") problems.push("RSA keys are permitted only for Windows devices");
|
|
57
|
+
} catch { problems.push("public key must be P-256 SPKI or Windows RSA-2048 SPKI"); }
|
|
58
|
+
}
|
|
59
|
+
return problems;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function eventHash(event) {
|
|
63
|
+
return digest(Buffer.from(canonicalJson(event?.payload), "utf8"));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const payloadKeys = ["domain", "rootFingerprint", "sequence", "previousHash", "operation", "subject", "signerKeyId"];
|
|
67
|
+
function validatePayload(payload) {
|
|
68
|
+
const problems = [];
|
|
69
|
+
if (!exact(payload, payloadKeys)) return ["event payload fields are malformed"];
|
|
70
|
+
if (payload.domain !== DOMAIN) problems.push("event domain is invalid");
|
|
71
|
+
if (!HEX.test(payload.rootFingerprint ?? "")) problems.push("root fingerprint is invalid");
|
|
72
|
+
if (!Number.isSafeInteger(payload.sequence) || payload.sequence < 0) problems.push("event sequence is invalid");
|
|
73
|
+
if (payload.sequence === 0 ? payload.previousHash !== null : !HEX.test(payload.previousHash ?? "")) problems.push("event previous hash is invalid");
|
|
74
|
+
if (!OPERATIONS.has(payload.operation)) problems.push("event operation is invalid");
|
|
75
|
+
if (!HEX.test(payload.signerKeyId ?? "")) problems.push("event signer key id is invalid");
|
|
76
|
+
if (payload.operation === "revoke") {
|
|
77
|
+
if (!exact(payload.subject, ["keyId"]) || !HEX.test(payload.subject?.keyId ?? "")) problems.push("revocation subject is malformed");
|
|
78
|
+
} else problems.push(...validateKeyRecord(payload.subject).map((p) => `subject ${p}`));
|
|
79
|
+
return problems;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function verifyKeyLog(log, { rootFingerprint, checkpoint } = {}) {
|
|
83
|
+
const problems = [];
|
|
84
|
+
const fail = (message) => problems.push(message);
|
|
85
|
+
if (!exact(log, ["schema", "events"]) || log.schema !== 1 || !Array.isArray(log.events)) {
|
|
86
|
+
return { ok: false, problems: ["key log schema or events are malformed"], keys: [], allKeys: [], revokedKeyIds: [], checkpoint: null, rootFingerprint: null, rootPublicKeyPem: null };
|
|
87
|
+
}
|
|
88
|
+
if (!HEX.test(rootFingerprint ?? "")) fail("pinned root fingerprint is required");
|
|
89
|
+
if (log.events.length === 0) fail("key log must contain a signed root event");
|
|
90
|
+
if (log.events.length > 10_000) fail("key log exceeds 10000 events");
|
|
91
|
+
if (problems.length) return { ok: false, problems, keys: [], allKeys: [], revokedKeyIds: [], checkpoint: null, rootFingerprint: rootFingerprint ?? null, rootPublicKeyPem: null };
|
|
92
|
+
const keys = new Map();
|
|
93
|
+
const allKeys = new Map();
|
|
94
|
+
const revoked = new Set();
|
|
95
|
+
let rootPem = null;
|
|
96
|
+
let previousHash = null;
|
|
97
|
+
for (let i = 0; i < log.events.length; i++) {
|
|
98
|
+
const event = log.events[i];
|
|
99
|
+
if (!exact(event, ["schema", "payload", "signature"]) || event.schema !== 1
|
|
100
|
+
|| (event.signature !== null && !exact(event.signature, ["keyId", "host", "value"]))) { fail(`event ${i} envelope is malformed or unsigned`); break; }
|
|
101
|
+
const payload = event.payload;
|
|
102
|
+
const payloadProblems = validatePayload(payload);
|
|
103
|
+
if (payloadProblems.length) { payloadProblems.forEach((p) => fail(`event ${i}: ${p}`)); break; }
|
|
104
|
+
if (payload.sequence !== i) { fail(`event ${i} sequence is not contiguous`); break; }
|
|
105
|
+
if (payload.previousHash !== previousHash) { fail(`event ${i} previous hash does not match log head`); break; }
|
|
106
|
+
if (rootFingerprint && payload.rootFingerprint !== rootFingerprint) { fail(`event ${i} root fingerprint does not match pin`); break; }
|
|
107
|
+
if (i === 0 && payload.operation !== "root") { fail("first event must establish root"); break; }
|
|
108
|
+
if (i > 0 && payload.operation === "root") { fail(`event ${i} cannot replace root`); break; }
|
|
109
|
+
if (!rootFingerprint && i === 0 && payload.rootFingerprint !== payload.subject.keyId) { fail("root event fingerprint does not match root key"); break; }
|
|
110
|
+
|
|
111
|
+
const signer = i === 0 ? payload.subject : keys.get(payload.signerKeyId);
|
|
112
|
+
if (!signer || signer.keyId !== payload.signerKeyId || revoked.has(payload.signerKeyId)) { fail(`event ${i} signer is not active`); break; }
|
|
113
|
+
const signerKey = createPublicKey(signer.publicKeyPem);
|
|
114
|
+
const rsaSigner = signerKey.asymmetricKeyType === "rsa";
|
|
115
|
+
if (event.signature === null) {
|
|
116
|
+
const permittedWindowsAnchor = i === 0 && payload.operation === "root" && signer.platform === "windows" && rsaSigner
|
|
117
|
+
&& payload.rootFingerprint === rootFingerprint && payload.rootFingerprint === payload.subject.keyId;
|
|
118
|
+
if (!permittedWindowsAnchor) { fail(`event ${i} unsigned root is not a pinned Windows RSA genesis checkpoint`); break; }
|
|
119
|
+
} else {
|
|
120
|
+
const maxLength = rsaSigner ? 344 : 128;
|
|
121
|
+
const signatureBytes = typeof event.signature.value === "string" ? Buffer.from(event.signature.value, "base64") : Buffer.alloc(0);
|
|
122
|
+
if (event.signature.keyId !== payload.signerKeyId || event.signature.host !== signer.platform || typeof event.signature.value !== "string"
|
|
123
|
+
|| event.signature.value.length > maxLength
|
|
124
|
+
|| (rsaSigner ? event.signature.value.length !== 344 || signatureBytes.length !== 256 : event.signature.value.length > 128 || signatureBytes.length === 0)
|
|
125
|
+
|| !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(event.signature.value)
|
|
126
|
+
|| signatureBytes.toString("base64") !== event.signature.value) { fail(`event ${i} signature envelope does not match signer`); break; }
|
|
127
|
+
const verified = verifyOwnershipSignature(signer.publicKeyPem, Buffer.from(canonicalJson(payload), "utf8"), signatureBytes);
|
|
128
|
+
if (!verified) { fail(`event ${i} signature is invalid`); break; }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (i === 0) {
|
|
132
|
+
if (payload.signerKeyId !== payload.subject.keyId || payload.rootFingerprint !== keyFingerprint(payload.subject.publicKeyPem)) { fail("root event is not self-signed or pin does not match"); break; }
|
|
133
|
+
rootPem = payload.subject.publicKeyPem;
|
|
134
|
+
keys.set(signer.keyId, signer);
|
|
135
|
+
allKeys.set(signer.keyId, signer);
|
|
136
|
+
} else if (payload.operation === "add") {
|
|
137
|
+
if (allKeys.has(payload.subject.keyId)) { fail(`event ${i} adds a duplicate or previously revoked key`); break; }
|
|
138
|
+
keys.set(payload.subject.keyId, payload.subject);
|
|
139
|
+
allKeys.set(payload.subject.keyId, payload.subject);
|
|
140
|
+
} else if (payload.operation === "revoke") {
|
|
141
|
+
if (!keys.has(payload.subject.keyId)) { fail(`event ${i} revokes a key that is not active`); break; }
|
|
142
|
+
keys.delete(payload.subject.keyId);
|
|
143
|
+
revoked.add(payload.subject.keyId);
|
|
144
|
+
}
|
|
145
|
+
previousHash = eventHash(event);
|
|
146
|
+
}
|
|
147
|
+
const head = log.events.length ? previousHash : null;
|
|
148
|
+
const current = { sequence: log.events.length - 1, head };
|
|
149
|
+
if (!problems.length && checkpoint !== undefined) {
|
|
150
|
+
if (!exact(checkpoint, ["sequence", "head"]) || !Number.isSafeInteger(checkpoint.sequence) || checkpoint.sequence < 0 || !HEX.test(checkpoint.head ?? "")) fail("protected checkpoint is malformed");
|
|
151
|
+
else if (current.sequence < checkpoint.sequence) fail("key log is shorter than protected checkpoint");
|
|
152
|
+
else if (log.events[checkpoint.sequence] && eventHash(log.events[checkpoint.sequence]) !== checkpoint.head) fail("key log forks before or at protected checkpoint");
|
|
153
|
+
}
|
|
154
|
+
const pin = rootFingerprint ?? (log.events[0]?.payload?.rootFingerprint ?? null);
|
|
155
|
+
return { ok: problems.length === 0, problems, keys: [...keys.values()], allKeys: [...allKeys.values()], revokedKeyIds: [...revoked], checkpoint: current, rootFingerprint: pin, rootPublicKeyPem: rootPem };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function createKeyEvent(log, { operation, subject, signerKeyId } = {}) {
|
|
159
|
+
if (!OPERATIONS.has(operation) || !HEX.test(signerKeyId ?? "")) throw new Error("operation and signer key id are required");
|
|
160
|
+
const events = log?.events ?? [];
|
|
161
|
+
const pin = events[0]?.payload?.rootFingerprint ?? (operation === "root" ? subject?.keyId : null);
|
|
162
|
+
const emptyLog = !log || (log.schema === 1 && Array.isArray(log.events) && log.events.length === 0);
|
|
163
|
+
const state = emptyLog && operation === "root"
|
|
164
|
+
? { ok: true, problems: [], keys: [], allKeys: [] }
|
|
165
|
+
: verifyKeyLog(log ?? { schema: 1, events: [] }, { rootFingerprint: pin });
|
|
166
|
+
if (!state.ok) throw new Error(`cannot extend invalid key log: ${state.problems.join("; ")}`);
|
|
167
|
+
const seq = events.length;
|
|
168
|
+
const previousHash = seq ? eventHash(events[seq - 1]) : null;
|
|
169
|
+
const payload = { domain: DOMAIN, rootFingerprint: pin, sequence: seq, previousHash, operation, subject, signerKeyId };
|
|
170
|
+
const errors = validatePayload(payload);
|
|
171
|
+
if (errors.length) throw new Error(errors.join("; "));
|
|
172
|
+
if (operation === "root" && (seq !== 0 || signerKeyId !== subject.keyId || pin !== subject.keyId)) throw new Error("root event must be first and self-signed");
|
|
173
|
+
if (operation !== "root" && !state.keys.some((key) => key.keyId === signerKeyId)) throw new Error("signer is not active");
|
|
174
|
+
if (operation === "add" && state.allKeys.some((key) => key.keyId === subject.keyId)) throw new Error("key already appeared in log");
|
|
175
|
+
if (operation === "revoke" && !state.keys.some((key) => key.keyId === subject.keyId)) throw new Error("revoked key is not active");
|
|
176
|
+
return { schema: 1, payload, signature: null };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function verifyKeyRequest(request, { rootFingerprint } = {}) {
|
|
180
|
+
const problems = [];
|
|
181
|
+
if (!exact(request, ["schema", "type", "rootFingerprint", "key"]) || request.schema !== 1 || request.type !== REQUEST_TYPE) problems.push("key request schema or type is malformed");
|
|
182
|
+
if (request?.rootFingerprint !== rootFingerprint || !HEX.test(rootFingerprint ?? "")) problems.push("key request root fingerprint does not match pin");
|
|
183
|
+
problems.push(...validateKeyRecord(request?.key).map((p) => `request key ${p}`));
|
|
184
|
+
return { ok: problems.length === 0, problems };
|
|
185
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { spawnSync } from "./background-process.mjs";
|
|
6
|
+
import { canonicalJson, canonicalRepositoryIdentity, __testOnly as approvalTestOnly, evaluateWindowsAcl, validateTrustRegistry } from "./ownership-approval.mjs";
|
|
7
|
+
import { verifyKeyLog } from "./ownership-key-log.mjs";
|
|
8
|
+
|
|
9
|
+
const HEX = /^[a-f0-9]{64}$/;
|
|
10
|
+
const MAX_REQUEST_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
const MAX_TRUST_BYTES = 2 * 1024 * 1024;
|
|
12
|
+
export const OWNERSHIP_TRUST_PATH = Object.freeze({
|
|
13
|
+
win32: "C:\\ProgramData\\Orthic Labs\\RightKit\\ownership\\trust.json",
|
|
14
|
+
darwin: "/Library/Application Support/Orthic Labs/RightKit/ownership/trust.json",
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function copyJson(value) { return JSON.parse(JSON.stringify(value)); }
|
|
18
|
+
function exactObject(value, keys) { return value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).sort().join("\0") === [...keys].sort().join("\0"); }
|
|
19
|
+
function sameCheckoutPath(left, right) {
|
|
20
|
+
const a = path.resolve(left), b = path.resolve(right);
|
|
21
|
+
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeKeyAuthority(authority) {
|
|
25
|
+
if (!exactObject(authority, ["repositoryIdentity", "checkoutPath"])) throw new Error("key authority fields must be exactly repositoryIdentity and checkoutPath");
|
|
26
|
+
let repositoryIdentity;
|
|
27
|
+
try { repositoryIdentity = canonicalRepositoryIdentity(authority.repositoryIdentity); }
|
|
28
|
+
catch { throw new Error("key authority repository identity is invalid"); }
|
|
29
|
+
if (repositoryIdentity !== authority.repositoryIdentity) throw new Error("key authority repository identity must be canonical HTTPS");
|
|
30
|
+
if (typeof authority.checkoutPath !== "string" || !path.isAbsolute(authority.checkoutPath) || authority.checkoutPath.includes("\0")) throw new Error("key authority checkout path must be absolute");
|
|
31
|
+
const checkoutPath = path.resolve(authority.checkoutPath);
|
|
32
|
+
if (!checkoutPath) throw new Error("key authority checkout path is invalid");
|
|
33
|
+
return { repositoryIdentity, checkoutPath };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Merge a fully verified key log into protected policy without changing policy fields. */
|
|
37
|
+
export function prepareTrustUpdate(existing, { log, rootFingerprint, baseline, authority } = {}) {
|
|
38
|
+
if (!HEX.test(rootFingerprint ?? "")) throw new Error("root fingerprint must be a lowercase SHA-256 SPKI fingerprint");
|
|
39
|
+
const current = existing == null ? null : copyJson(existing);
|
|
40
|
+
if (current && (current.schema !== 1 || typeof current !== "object" || Array.isArray(current))) throw new Error("protected trust schema is unsupported");
|
|
41
|
+
if (current && Object.hasOwn(current, "rootFingerprint") && !current.keyCheckpoint) throw new Error("protected root trust lacks checkpoint; refusing update");
|
|
42
|
+
if (!current && baseline && (Object.hasOwn(baseline, "rootFingerprint") || Object.hasOwn(baseline, "keyCheckpoint") || Object.hasOwn(baseline, "keyAuthority"))) throw new Error("first-install baseline may contain policy only");
|
|
43
|
+
if (current && !validateTrustRegistry(current).ok) throw new Error(`existing protected policy is invalid: ${validateTrustRegistry(current).problems.join("; ")}`);
|
|
44
|
+
if (!current && baseline && !validateTrustRegistry(baseline).ok) throw new Error(`first-install policy baseline is invalid: ${validateTrustRegistry(baseline).problems.join("; ")}`);
|
|
45
|
+
if (current?.rootFingerprint && current.rootFingerprint !== rootFingerprint) throw new Error("protected root pin differs; refusing root rotation");
|
|
46
|
+
const incomingAuthority = authority === undefined ? undefined : normalizeKeyAuthority(authority);
|
|
47
|
+
const existingAuthority = current?.keyAuthority === undefined ? undefined : normalizeKeyAuthority(current.keyAuthority);
|
|
48
|
+
if (existingAuthority && (!sameCheckoutPath(existingAuthority.checkoutPath, current.keyAuthority.checkoutPath) || existingAuthority.repositoryIdentity !== current.keyAuthority.repositoryIdentity)) throw new Error("protected key authority is not canonical");
|
|
49
|
+
if (existingAuthority && incomingAuthority && (existingAuthority.repositoryIdentity !== incomingAuthority.repositoryIdentity || !sameCheckoutPath(existingAuthority.checkoutPath, incomingAuthority.checkoutPath))) throw new Error("protected key authority differs; refusing repository or checkout relocation");
|
|
50
|
+
const priorCheckpoint = current?.keyCheckpoint;
|
|
51
|
+
const state = verifyKeyLog(log, { rootFingerprint, ...(priorCheckpoint ? { checkpoint: priorCheckpoint } : {}) });
|
|
52
|
+
if (!state.ok) throw new Error(`key log rejected: ${state.problems.join("; ")}`);
|
|
53
|
+
if (!state.checkpoint || state.checkpoint.sequence < 0 || !HEX.test(state.checkpoint.head ?? "")) throw new Error("key log has no root checkpoint");
|
|
54
|
+
let policy;
|
|
55
|
+
if (current) policy = current;
|
|
56
|
+
else {
|
|
57
|
+
if (!baseline || typeof baseline !== "object" || Array.isArray(baseline) || baseline.schema !== 1) throw new Error("first root pin requires a schema 1 policy baseline");
|
|
58
|
+
policy = copyJson(baseline);
|
|
59
|
+
}
|
|
60
|
+
const selectedAuthority = current?.keyAuthority ?? incomingAuthority;
|
|
61
|
+
return { ...policy, schema: 1, rootFingerprint, keyCheckpoint: state.checkpoint, ...(selectedAuthority ? { keyAuthority: selectedAuthority } : {}) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function runNativeGit(checkoutPath, args, runner = spawnSync) {
|
|
65
|
+
const nullConfig = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
66
|
+
const nativeGit = process.platform === "win32" ? "C:\\Program Files\\Git\\cmd\\git.exe" : process.platform === "darwin" ? "/usr/bin/git" : "/usr/bin/git";
|
|
67
|
+
const fixedPath = process.platform === "win32" ? "C:\\Windows\\System32;C:\\Windows" : "/usr/bin:/bin:/usr/sbin:/sbin";
|
|
68
|
+
const env = {
|
|
69
|
+
PATH: fixedPath,
|
|
70
|
+
...(process.platform === "win32" ? { SystemRoot: "C:\\Windows", WINDIR: "C:\\Windows" } : {}),
|
|
71
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
72
|
+
GIT_CONFIG_GLOBAL: nullConfig,
|
|
73
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
74
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
75
|
+
GIT_NO_REPLACE_OBJECTS: "1",
|
|
76
|
+
GIT_PAGER: "cat",
|
|
77
|
+
};
|
|
78
|
+
const r = runner(nativeGit, ["--no-replace-objects", "-c", "core.fsmonitor=false", "-c", `core.hooksPath=${nullConfig}`, "-c", `safe.directory=${checkoutPath}`, "-C", checkoutPath, ...args], { encoding: null, windowsHide: true, timeout: 10000, maxBuffer: MAX_REQUEST_BYTES, env });
|
|
79
|
+
if (r.error || r.status !== 0 || !Buffer.isBuffer(r.stdout) || r.stdout.length > MAX_REQUEST_BYTES) throw new Error(`bounded native Git query failed: ${args[0]}`);
|
|
80
|
+
return r.stdout;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readCommittedAuthorityData(authority, rootFingerprint, checkpoint, runner = spawnSync) {
|
|
84
|
+
const normalized = normalizeKeyAuthority(authority);
|
|
85
|
+
let realCheckout;
|
|
86
|
+
try { realCheckout = realpathSync.native(normalized.checkoutPath); }
|
|
87
|
+
catch { throw new Error("key authority checkout path does not exist"); }
|
|
88
|
+
if (!sameCheckoutPath(realCheckout, normalized.checkoutPath)) throw new Error("key authority checkout path is not its canonical local path");
|
|
89
|
+
const top = runNativeGit(realCheckout, ["rev-parse", "--show-toplevel"], runner).toString("utf8").trim();
|
|
90
|
+
if (!sameCheckoutPath(realpathSync.native(top), realCheckout)) throw new Error("key authority path must be repository root");
|
|
91
|
+
const origin = runNativeGit(realCheckout, ["config", "--local", "--get", "remote.origin.url"], runner).toString("utf8").trim();
|
|
92
|
+
let canonicalOrigin;
|
|
93
|
+
try { canonicalOrigin = canonicalRepositoryIdentity(origin); }
|
|
94
|
+
catch { throw new Error("key authority repository origin is invalid"); }
|
|
95
|
+
if (canonicalOrigin !== normalized.repositoryIdentity) throw new Error("key authority repository origin does not match protected identity");
|
|
96
|
+
const commit = runNativeGit(realCheckout, ["rev-parse", "--verify", "HEAD^{commit}"], runner).toString("utf8").trim();
|
|
97
|
+
if (!/^[a-f0-9]{40,64}$/.test(commit)) throw new Error("repository HEAD commit identifier is malformed");
|
|
98
|
+
const tree = runNativeGit(realCheckout, ["rev-parse", "--verify", `${commit}^{tree}`], runner).toString("utf8").trim();
|
|
99
|
+
const treeEntry = runNativeGit(realCheckout, ["ls-tree", "-z", "--full-tree", commit, "--", ".rightkit/ownership-keys.json"], runner).toString("utf8");
|
|
100
|
+
const entries = treeEntry.split("\0").filter(Boolean);
|
|
101
|
+
if (entries.length !== 1) throw new Error("committed ownership key log is missing or ambiguous");
|
|
102
|
+
const match = entries[0].match(/^(100644|100755) blob ([a-f0-9]{40,64})\t\.rightkit\/ownership-keys\.json$/);
|
|
103
|
+
if (!match) throw new Error("committed ownership key log must be a regular blob, not a gitlink or special entry");
|
|
104
|
+
const committedBytes = runNativeGit(realCheckout, ["show", `${commit}:.rightkit/ownership-keys.json`], runner);
|
|
105
|
+
if (committedBytes.length > MAX_REQUEST_BYTES) throw new Error("committed ownership key log exceeds size limit");
|
|
106
|
+
const worktreePath = path.join(realCheckout, ".rightkit", "ownership-keys.json");
|
|
107
|
+
let worktreeStat;
|
|
108
|
+
try { worktreeStat = lstatSync(worktreePath); } catch { throw new Error("committed ownership key log is missing from protected checkout"); }
|
|
109
|
+
if (!worktreeStat.isFile() || worktreeStat.isSymbolicLink() || (process.platform === "win32" && (worktreeStat.attributes & 0x400)) || worktreeStat.size > MAX_REQUEST_BYTES) throw new Error("working checkout ownership key log must be a bounded regular file");
|
|
110
|
+
const currentBytes = readFileSync(worktreePath);
|
|
111
|
+
if (!currentBytes.equals(committedBytes)) throw new Error("working checkout ownership key log differs from committed bytes");
|
|
112
|
+
let committedLog;
|
|
113
|
+
try { committedLog = JSON.parse(committedBytes.toString("utf8")); }
|
|
114
|
+
catch { throw new Error("committed ownership key log is malformed JSON"); }
|
|
115
|
+
const state = verifyKeyLog(committedLog, { rootFingerprint, checkpoint });
|
|
116
|
+
if (!state.ok) throw new Error(`committed key log rejected: ${state.problems.join("; ")}`);
|
|
117
|
+
if (!/^[a-f0-9]{40,64}$/.test(tree)) throw new Error("repository tree identifier is malformed");
|
|
118
|
+
return { log: committedLog, bytes: committedBytes, digest: createHash("sha256").update(committedBytes).digest("hex"), rootFingerprint: state.rootFingerprint, keyCheckpoint: state.checkpoint, authority: { repositoryIdentity: normalized.repositoryIdentity, commit, tree } };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function validateCommittedAuthority(authority, log, rootFingerprint, checkpoint, runner = spawnSync) {
|
|
122
|
+
const committed = readCommittedAuthorityData(authority, rootFingerprint, checkpoint, runner);
|
|
123
|
+
if (canonicalJson(committed.log) !== canonicalJson(log)) throw new Error("request key log differs from committed ownership key log");
|
|
124
|
+
return committed.authority;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Read & verify authority-bound committed key state from its pinned local checkout. */
|
|
128
|
+
export function readCommittedKeyAuthority(trust) {
|
|
129
|
+
try {
|
|
130
|
+
if (!trust?.keyAuthority || !HEX.test(trust.rootFingerprint ?? "") || !trust.keyCheckpoint) throw new Error("protected key authority, root pin, or checkpoint is unavailable");
|
|
131
|
+
return { ok: true, ...readCommittedAuthorityData(trust.keyAuthority, trust.rootFingerprint, trust.keyCheckpoint) };
|
|
132
|
+
} catch (error) { return { ok: false, problems: [error.message] }; }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function ensureNoLinks(file) {
|
|
136
|
+
const parsed = path.parse(file);
|
|
137
|
+
let current = parsed.root;
|
|
138
|
+
const pieces = file.slice(parsed.root.length).split(path.sep).filter(Boolean);
|
|
139
|
+
for (const part of pieces) {
|
|
140
|
+
current = path.join(current, part);
|
|
141
|
+
try {
|
|
142
|
+
const st = lstatSync(current);
|
|
143
|
+
if (st.isSymbolicLink() || (process.platform === "win32" && (st.attributes & 0x400))) throw new Error(`trust path contains a symlink or reparse point: ${current}`);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error.code !== "ENOENT") throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function assertElevated() {
|
|
151
|
+
if (process.platform === "win32") {
|
|
152
|
+
const r = spawnSync("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "$p=New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()); if (!$p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { exit 9 }"]);
|
|
153
|
+
if (r.status !== 0) throw new Error("protected trust updater requires an elevated Administrator process");
|
|
154
|
+
} else if (process.platform === "darwin") {
|
|
155
|
+
if (process.getuid?.() !== 0) throw new Error("protected trust updater requires root");
|
|
156
|
+
} else throw new Error(`protected trust update unsupported platform: ${process.platform}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function hardenWindows(directory, file, runner = spawnSync) {
|
|
160
|
+
const script = "$ErrorActionPreference='Stop'; $sidSys=New-Object Security.Principal.SecurityIdentifier('S-1-5-18'); $sidAdm=New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544'); $sidUsers=New-Object Security.Principal.SecurityIdentifier('S-1-5-32-545'); foreach($p in @($env:RK_TRUST_DIR,$env:RK_TRUST_FILE)){ $a=New-Object Security.AccessControl.DirectorySecurity; if($p -eq $env:RK_TRUST_FILE){$a=New-Object Security.AccessControl.FileSecurity}; $a.SetAccessRuleProtection($true,$false); $a.SetOwner($sidAdm); $inherit=if($p -eq $env:RK_TRUST_FILE){[Security.AccessControl.InheritanceFlags]::None}else{[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit'}; foreach($sid in @($sidSys,$sidAdm)){ $rule=New-Object Security.AccessControl.FileSystemAccessRule($sid,'FullControl',$inherit,[Security.AccessControl.PropagationFlags]::None,'Allow'); $a.AddAccessRule($rule) }; $read=New-Object Security.AccessControl.FileSystemAccessRule($sidUsers,'ReadAndExecute',$inherit,[Security.AccessControl.PropagationFlags]::None,'Allow'); $a.AddAccessRule($read); Set-Acl -LiteralPath $p -AclObject $a }; $a=Get-Acl -LiteralPath $env:RK_TRUST_DIR; $a.SetOwner($sidSys); Set-Acl -LiteralPath $env:RK_TRUST_DIR -AclObject $a";
|
|
161
|
+
const r = runner("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: { ...process.env, RK_TRUST_DIR: directory, RK_TRUST_FILE: file } });
|
|
162
|
+
if (r.status !== 0) throw new Error("could not secure protected trust ACL");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const __testOnly = Object.freeze({
|
|
166
|
+
runWindowsAclSetupInDisposableDirectory(directory, runner) {
|
|
167
|
+
const file = path.join(directory, "trust.json");
|
|
168
|
+
hardenWindows(directory, file, runner);
|
|
169
|
+
return { directory, file };
|
|
170
|
+
},
|
|
171
|
+
validateCommittedAuthority,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
function runWindowsAclScript(script, environment) {
|
|
175
|
+
const r = spawnSync("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: { ...process.env, ...environment } });
|
|
176
|
+
if (r.status !== 0) throw new Error("could not create or secure protected ownership path");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function setWindowsDirectoryAcl(directory) {
|
|
180
|
+
const script = "$ErrorActionPreference='Stop'; $d=$env:RK_TRUST_DIR; $sidSys=New-Object Security.Principal.SecurityIdentifier('S-1-5-18'); $sidAdm=New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544'); $sidUsers=New-Object Security.Principal.SecurityIdentifier('S-1-5-32-545'); $a=New-Object Security.AccessControl.DirectorySecurity; $a.SetAccessRuleProtection($true,$false); $a.SetOwner($sidAdm); foreach($sid in @($sidSys,$sidAdm)){ $a.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'FullControl','ContainerInherit,ObjectInherit','None','Allow'))) }; $a.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sidUsers,'ReadAndExecute','ContainerInherit,ObjectInherit','None','Allow'))); Set-Acl -LiteralPath $d -AclObject $a";
|
|
181
|
+
runWindowsAclScript(script, { RK_TRUST_DIR: directory });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function prepareDirectory(directory) {
|
|
185
|
+
ensureNoLinks(directory);
|
|
186
|
+
if (process.platform === "win32") {
|
|
187
|
+
const root = path.parse(directory).root;
|
|
188
|
+
const remainder = directory.slice(root.length).split(path.sep).filter(Boolean);
|
|
189
|
+
let current = root;
|
|
190
|
+
for (const part of remainder) {
|
|
191
|
+
current = path.join(current, part);
|
|
192
|
+
let created = false;
|
|
193
|
+
try { const st = lstatSync(current); if (!st.isDirectory() || st.isSymbolicLink() || (st.attributes & 0x400)) throw new Error(`protected trust parent is unsafe: ${current}`); }
|
|
194
|
+
catch (error) { if (error.code !== "ENOENT") throw error; mkdirSync(current); created = true; }
|
|
195
|
+
if (created) setWindowsDirectoryAcl(current);
|
|
196
|
+
else auditProtected(current);
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
const root = "/";
|
|
200
|
+
const remainder = directory.slice(root.length).split(path.sep).filter(Boolean);
|
|
201
|
+
let current = root;
|
|
202
|
+
for (const part of remainder) {
|
|
203
|
+
current = path.join(current, part);
|
|
204
|
+
let created = false;
|
|
205
|
+
try { const st = lstatSync(current); if (!st.isDirectory() || st.isSymbolicLink()) throw new Error(`protected trust parent is unsafe: ${current}`); }
|
|
206
|
+
catch (error) { if (error.code !== "ENOENT") throw error; mkdirSync(current, { mode: 0o755 }); created = true; }
|
|
207
|
+
if (created) {
|
|
208
|
+
const chown = spawnSync("/usr/sbin/chown", ["root:wheel", current]);
|
|
209
|
+
const chmod = spawnSync("/bin/chmod", ["755", current]);
|
|
210
|
+
if (chown.status !== 0 || chmod.status !== 0) throw new Error(`could not secure protected ownership directory: ${current}`);
|
|
211
|
+
} else auditProtected(current);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function auditProtected(file) {
|
|
217
|
+
if (process.platform === "win32") {
|
|
218
|
+
let current = path.dirname(file);
|
|
219
|
+
const targets = [];
|
|
220
|
+
while (true) { targets.push(current); if (current.toLowerCase() === "c:\\") break; current = path.dirname(current); }
|
|
221
|
+
targets.push(file);
|
|
222
|
+
const script = "$ErrorActionPreference='Stop'; $a=Get-Acl -LiteralPath $env:RK_TRUST_CHECK_TARGET; $item=Get-Item -Force -LiteralPath $env:RK_TRUST_CHECK_TARGET; $out=[ordered]@{ownerSID=$a.GetOwner([Security.Principal.SecurityIdentifier]).Value;ownerName=$a.Owner;attributes=$item.Attributes.ToString();directory=[bool]$item.PSIsContainer;nonempty=(!$item.PSIsContainer -or [System.IO.Directory]::EnumerateFileSystemEntries($item.FullName).GetEnumerator().MoveNext());elevated=$true;access=@($a.Access | ForEach-Object { [ordered]@{identitySID=$_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value;type=$_.AccessControlType.ToString();mask=[int64]$_.FileSystemRights.value__;propagationFlags=[int]$_.PropagationFlags} })}; $out | ConvertTo-Json -Compress -Depth 5";
|
|
223
|
+
const problems = [];
|
|
224
|
+
for (const target of targets) {
|
|
225
|
+
try { if (lstatSync(target).isSymbolicLink()) problems.push(`trust path contains a reparse/symlink: ${target}`); } catch { problems.push(`cannot inspect trust path: ${target}`); continue; }
|
|
226
|
+
const r = spawnSync("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true, env: { ...process.env, RK_TRUST_CHECK_TARGET: target } });
|
|
227
|
+
if (r.status !== 0) { problems.push(`cannot inspect trust ACL: ${target}`); continue; }
|
|
228
|
+
try { problems.push(...evaluateWindowsAcl(JSON.parse(r.stdout.trim()), { target, requireNonElevated: false }).problems.map((problem) => `${problem}: ${target}`)); }
|
|
229
|
+
catch { problems.push(`malformed ACL result: ${target}`); }
|
|
230
|
+
}
|
|
231
|
+
if (problems.length) throw new Error(`protected trust audit failed: ${problems.join("; ")}`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const result = approvalTestOnly.protectedLocation(file, process.platform);
|
|
235
|
+
if (!result.ok) throw new Error(`protected trust audit failed: ${result.problems.join("; ")}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function readExisting(file) {
|
|
239
|
+
try {
|
|
240
|
+
const st = lstatSync(file);
|
|
241
|
+
if (st.isSymbolicLink() || !st.isFile() || st.size > MAX_TRUST_BYTES) throw new Error("existing protected trust must be a bounded regular file");
|
|
242
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
243
|
+
} catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function acquireUpdateLock(directory) {
|
|
247
|
+
const lock = path.join(directory, ".key-trust-update.lock");
|
|
248
|
+
let fd;
|
|
249
|
+
try { fd = openSync(lock, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); }
|
|
250
|
+
catch (error) { if (error.code === "EEXIST") throw new Error("another ownership trust update is active; refusing concurrent update"); throw error; }
|
|
251
|
+
try { writeFileSync(fd, `${process.pid}\n`); fsyncSync(fd); }
|
|
252
|
+
catch (error) { closeSync(fd); try { unlinkSync(lock); } catch {} throw error; }
|
|
253
|
+
closeSync(fd);
|
|
254
|
+
return () => { try { unlinkSync(lock); } catch (error) { if (error.code !== "ENOENT") throw error; } };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function atomicProtectedWrite(file, trust) {
|
|
258
|
+
const directory = path.dirname(file);
|
|
259
|
+
const temp = path.join(directory, `.trust-${process.pid}-${Date.now()}.tmp`);
|
|
260
|
+
let fd;
|
|
261
|
+
try {
|
|
262
|
+
fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
263
|
+
writeFileSync(fd, `${JSON.stringify(trust, null, 2)}\n`);
|
|
264
|
+
fsyncSync(fd);
|
|
265
|
+
closeSync(fd); fd = undefined;
|
|
266
|
+
if (process.platform === "win32") hardenWindows(directory, temp);
|
|
267
|
+
else {
|
|
268
|
+
const chown = spawnSync("/usr/sbin/chown", ["root:wheel", temp]);
|
|
269
|
+
if (chown.status !== 0) throw new Error("could not set protected trust file owner");
|
|
270
|
+
const modeFd = openSync(temp, constants.O_RDONLY);
|
|
271
|
+
try { fchmodSync(modeFd, 0o644); } finally { closeSync(modeFd); }
|
|
272
|
+
}
|
|
273
|
+
renameSync(temp, file);
|
|
274
|
+
if (process.platform !== "win32") {
|
|
275
|
+
const dirFd = openSync(directory, constants.O_RDONLY);
|
|
276
|
+
try { fsyncSync(dirFd); } finally { closeSync(dirFd); }
|
|
277
|
+
}
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (fd !== undefined) closeSync(fd);
|
|
280
|
+
try { unlinkSync(temp); } catch {}
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Elevated child entrypoint. Writes only the fixed native trust path. */
|
|
286
|
+
export function performProtectedTrustUpdate({ log, rootFingerprint, baseline, authority } = {}) {
|
|
287
|
+
assertElevated();
|
|
288
|
+
const file = OWNERSHIP_TRUST_PATH[process.platform];
|
|
289
|
+
const directory = path.dirname(file);
|
|
290
|
+
prepareDirectory(directory);
|
|
291
|
+
auditProtected(directory);
|
|
292
|
+
ensureNoLinks(file);
|
|
293
|
+
const releaseLock = acquireUpdateLock(directory);
|
|
294
|
+
try {
|
|
295
|
+
// Re-read under the protected lock so every accepted checkpoint follows latest disk state.
|
|
296
|
+
let existing = null;
|
|
297
|
+
try { lstatSync(file); auditProtected(file); existing = readExisting(file); } catch (error) { if (error.code !== "ENOENT") throw error; existing = null; }
|
|
298
|
+
const trust = prepareTrustUpdate(existing, { log, rootFingerprint, baseline, authority });
|
|
299
|
+
if (trust.keyAuthority) validateCommittedAuthority(trust.keyAuthority, log, trust.rootFingerprint, trust.keyCheckpoint);
|
|
300
|
+
atomicProtectedWrite(file, trust);
|
|
301
|
+
auditProtected(file);
|
|
302
|
+
return { path: file, trust };
|
|
303
|
+
} finally { releaseLock(); }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function readPinnedRequest(requestPath, expectedDigest) {
|
|
307
|
+
if (!path.isAbsolute(requestPath) || !HEX.test(expectedDigest ?? "")) throw new Error("request path and SHA-256 digest are required");
|
|
308
|
+
const fd = openSync(requestPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
309
|
+
try {
|
|
310
|
+
const st = fstatSync(fd);
|
|
311
|
+
if (!st.isFile() || st.size < 2 || st.size > MAX_REQUEST_BYTES) throw new Error("request must be a bounded regular file");
|
|
312
|
+
const bytes = readFileSync(fd);
|
|
313
|
+
if (createHash("sha256").update(bytes).digest("hex") !== expectedDigest) throw new Error("request bytes do not match pinned SHA-256");
|
|
314
|
+
return JSON.parse(bytes.toString("utf8"));
|
|
315
|
+
} finally { closeSync(fd); }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
|
|
319
|
+
try {
|
|
320
|
+
if (process.argv.length !== 6 || process.argv[2] !== "--request" || process.argv[4] !== "--sha256") throw new Error("usage: ownership-key-trust.mjs --request <absolute-json> --sha256 <digest>");
|
|
321
|
+
const request = readPinnedRequest(process.argv[3], process.argv[5]);
|
|
322
|
+
if (!exactObject(request, ["schema", "log", "rootFingerprint", ...(Object.hasOwn(request ?? {}, "baseline") ? ["baseline"] : []), ...(Object.hasOwn(request ?? {}, "authority") ? ["authority"] : [])]) || request.schema !== 1 || !Array.isArray(request.log?.events) || request.log.schema !== 1) throw new Error("key trust request schema is malformed");
|
|
323
|
+
const result = performProtectedTrustUpdate({ log: request.log, rootFingerprint: request.rootFingerprint, baseline: request.baseline, authority: request.authority });
|
|
324
|
+
process.stdout.write(`${JSON.stringify({ path: result.path, checkpoint: result.trust.keyCheckpoint })}\n`);
|
|
325
|
+
} catch (error) { process.stderr.write(`${error.message}\n`); process.exitCode = 2; }
|
|
326
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { spawn } from "./background-process.mjs";
|
|
6
|
+
import { OWNERSHIP_APPS, formatOwnershipJson, buildOwnershipReport, renderOwnershipMarkdown } from "./ownership-report.mjs";
|
|
7
|
+
import { MANIFEST_FILE } from "./ownership.mjs";
|
|
8
|
+
|
|
9
|
+
const packageRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const defaultWorkspace = path.resolve(packageRoot, "../../../..");
|
|
11
|
+
const MAX_SCAN_OUTPUT_BYTES = 32 * 1024 * 1024;
|
|
12
|
+
const flag = (args, name) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
|
|
13
|
+
|
|
14
|
+
function scanWorker(appRoot, manifestPath, timeoutMs) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const worker = path.join(packageRoot, "ownership-scan-worker.mjs");
|
|
17
|
+
const child = spawn(process.execPath, [worker, appRoot, manifestPath], { stdio: ["ignore", "pipe", "pipe"] });
|
|
18
|
+
let stdout = "", stderr = "", outputBytes = 0, settled = false;
|
|
19
|
+
const finish = (value) => { if (settled) return; settled = true; clearTimeout(timer); resolve(value); };
|
|
20
|
+
child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8");
|
|
21
|
+
const collect = (target) => (chunk) => {
|
|
22
|
+
outputBytes += Buffer.byteLength(chunk);
|
|
23
|
+
if (outputBytes > MAX_SCAN_OUTPUT_BYTES) { child.kill(); finish({ error: `scanner output exceeded ${MAX_SCAN_OUTPUT_BYTES} bytes; marked unscanned` }); return; }
|
|
24
|
+
if (target === "stdout") stdout += chunk; else stderr += chunk;
|
|
25
|
+
};
|
|
26
|
+
child.stdout.on("data", collect("stdout")); child.stderr.on("data", collect("stderr"));
|
|
27
|
+
child.once("error", (error) => finish({ error: `scanner worker failed to start: ${error.message}` }));
|
|
28
|
+
child.once("close", (code) => {
|
|
29
|
+
if (code !== 0) return finish({ error: stderr.trim() || `scanner worker exited ${code}` });
|
|
30
|
+
try { finish({ value: JSON.parse(stdout) }); } catch (error) { finish({ error: `scanner worker returned invalid JSON: ${error.message}` }); }
|
|
31
|
+
});
|
|
32
|
+
const timer = setTimeout(() => { child.kill(); finish({ error: `scan exceeded ${timeoutMs} ms limit; marked unscanned` }); }, timeoutMs);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function scanConfiguredApps(workspaceRoot, manifest, timeoutMs) {
|
|
37
|
+
const results = new Map();
|
|
38
|
+
let cursor = 0;
|
|
39
|
+
const worker = async () => {
|
|
40
|
+
for (;;) {
|
|
41
|
+
const index = cursor++;
|
|
42
|
+
if (index >= OWNERSHIP_APPS.length) return;
|
|
43
|
+
const app = OWNERSHIP_APPS[index];
|
|
44
|
+
const appRoot = path.resolve(workspaceRoot, app.root);
|
|
45
|
+
if (!existsSync(appRoot)) continue;
|
|
46
|
+
results.set(app.key, await scanWorker(appRoot, path.join(packageRoot, MANIFEST_FILE), timeoutMs));
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
await Promise.all(Array.from({ length: Math.min(3, OWNERSHIP_APPS.length) }, worker));
|
|
50
|
+
return results;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function main(args, io = {}) {
|
|
54
|
+
const workspaceRoot = path.resolve(flag(args, "--workspace") ?? defaultWorkspace);
|
|
55
|
+
const manifest = JSON.parse(readFileSync(path.join(packageRoot, MANIFEST_FILE), "utf8"));
|
|
56
|
+
const versions = JSON.parse(readFileSync(path.join(packageRoot, "rightkit-versions.json"), "utf8"));
|
|
57
|
+
const timeoutMs = Number(flag(args, "--timeout-ms") ?? 60_000);
|
|
58
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 300_000) throw new TypeError("--timeout-ms must be between 1000 and 300000");
|
|
59
|
+
const scanResults = await scanConfiguredApps(workspaceRoot, manifest, timeoutMs);
|
|
60
|
+
const report = buildOwnershipReport({ workspaceRoot, manifest, versions, scanner: (root, catalog) => {
|
|
61
|
+
const app = OWNERSHIP_APPS.find((entry) => path.resolve(workspaceRoot, entry.root) === path.resolve(root));
|
|
62
|
+
if (!app) throw new Error("app root is not in configured app list");
|
|
63
|
+
const scanned = scanResults.get(app.key);
|
|
64
|
+
if (!scanned) throw new Error("configured app root missing; marked unscanned");
|
|
65
|
+
if (scanned.error) throw new Error(scanned.error);
|
|
66
|
+
return scanned.value;
|
|
67
|
+
} });
|
|
68
|
+
const markdown = renderOwnershipMarkdown(report);
|
|
69
|
+
const json = formatOwnershipJson(report);
|
|
70
|
+
const markdownPath = flag(args, "--markdown");
|
|
71
|
+
const jsonPath = flag(args, "--json-out");
|
|
72
|
+
if (markdownPath) writeFileSync(path.resolve(markdownPath), markdown);
|
|
73
|
+
if (jsonPath) writeFileSync(path.resolve(jsonPath), json);
|
|
74
|
+
if (args.includes("--json")) (io.stdout ?? process.stdout).write(json);
|
|
75
|
+
else {
|
|
76
|
+
if (!markdownPath && !jsonPath) (io.stdout ?? process.stdout).write(markdown);
|
|
77
|
+
(io.stderr ?? process.stderr).write(`[right-release] ownership report: ${report.apps.length} apps, ${report.blocks.length} deduplicated blocks, ${report.exceptionRequests.length} pending exception requests\n`);
|
|
78
|
+
if (markdownPath) (io.stderr ?? process.stderr).write(`[right-release] Markdown: ${path.resolve(markdownPath)}\n`);
|
|
79
|
+
if (jsonPath) (io.stderr ?? process.stderr).write(`[right-release] JSON: ${path.resolve(jsonPath)}\n`);
|
|
80
|
+
}
|
|
81
|
+
return report.apps.some((app) => app.status === "blocked" || app.status === "unscanned" || app.rows.some((row) => row.status === "blocked" || row.status === "unscanned")) ? 1 : 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
85
|
+
try { process.exitCode = await main(process.argv.slice(2)); }
|
|
86
|
+
catch (error) { console.error(`[right-release] ownership report: ${error.message}`); process.exitCode = 1; }
|
|
87
|
+
}
|