blun-king-cli 9.1.563 → 9.1.565
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/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
- package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
- package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
- package/agent-spine-plugin/.mcp.json +8 -8
- package/agent-spine-plugin/CONTRIBUTING.md +52 -0
- package/agent-spine-plugin/LICENSE +186 -186
- package/agent-spine-plugin/README.md +394 -0
- package/agent-spine-plugin/SECURITY.md +47 -0
- package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
- package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
- package/agent-spine-plugin/bin/agentspine.js +7 -7
- package/agent-spine-plugin/blun.plugin.json +33 -33
- package/agent-spine-plugin/hooks/codex.json +47 -47
- package/agent-spine-plugin/hooks/hooks.json +106 -106
- package/agent-spine-plugin/hooks/version.json +5 -5
- package/agent-spine-plugin/package.json +69 -69
- package/agent-spine-plugin/scripts/check-hosts.js +199 -199
- package/agent-spine-plugin/skill/SKILL.md +76 -76
- package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
- package/agent-spine-plugin/src/cli.js +1488 -1442
- package/agent-spine-plugin/src/hook.js +886 -812
- package/agent-spine-plugin/src/index.js +93 -93
- package/agent-spine-plugin/src/lib/acceptance.js +333 -333
- package/agent-spine-plugin/src/lib/attention.js +755 -755
- package/agent-spine-plugin/src/lib/audit.js +351 -342
- package/agent-spine-plugin/src/lib/authentication.js +515 -515
- package/agent-spine-plugin/src/lib/briefing.js +317 -317
- package/agent-spine-plugin/src/lib/catalog.js +167 -167
- package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
- package/agent-spine-plugin/src/lib/context.js +154 -154
- package/agent-spine-plugin/src/lib/continuity.js +338 -338
- package/agent-spine-plugin/src/lib/coordination.js +577 -577
- package/agent-spine-plugin/src/lib/documents.js +217 -217
- package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
- package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
- package/agent-spine-plugin/src/lib/gateway-runtime.js +1119 -760
- package/agent-spine-plugin/src/lib/graph.js +337 -337
- package/agent-spine-plugin/src/lib/hook-audit.js +29 -0
- package/agent-spine-plugin/src/lib/https-transport.js +392 -392
- package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
- package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
- package/agent-spine-plugin/src/lib/learning.js +6923 -6466
- package/agent-spine-plugin/src/lib/object-transport.js +206 -206
- package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
- package/agent-spine-plugin/src/lib/paths.js +109 -109
- package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
- package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
- package/agent-spine-plugin/src/lib/preflight.js +702 -702
- package/agent-spine-plugin/src/lib/runtime.js +13 -13
- package/agent-spine-plugin/src/lib/selfstarter.js +892 -819
- package/agent-spine-plugin/src/lib/sharing.js +969 -969
- package/agent-spine-plugin/src/lib/source-roots.js +529 -482
- package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
- package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
- package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
- package/agent-spine-plugin/src/mcp.js +597 -572
- package/agent-spine-plugin/src/version.js +1 -1
- package/agent-spine-plugin/src/worker.js +202 -195
- package/bin/active-steer-priority-policy.cjs +24 -0
- package/bin/launcher-runtime.js +8 -1
- package/bin/mnemo-tool-agent-policy.cjs +22 -0
- package/bin/thinking-activity-status-policy.cjs +132 -0
- package/bin/thinking-only-guard.cjs +75 -0
- package/bin/tool-call-loop-policy.cjs +53 -0
- package/bin/turn-thinking-policy.cjs +25 -1
- package/blun.mjs +554 -64
- package/package.json +4 -1
- package/standard-skills/translate-native/LICENSE +21 -21
- package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
- package/standard-skills/translate-native/references/native-orthography.md +79 -79
- package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
- package/standard-skills/translate-native/references/structured-content.md +72 -72
- package/standard-skills/translate-native/references/translationese-review.md +77 -77
- package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
- package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
- package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
- package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
- package/standard-skills/translate-native/scripts/language_quality.py +377 -377
- package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
- package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
- package/standard-tools/language-guard/blun_language_guard.py +697 -697
- package/standard-tools/language-guard/check_diacritics.py +353 -353
- package/standard-tools/language-guard/guard_service_client.py +264 -264
- package/standard-tools/language-guard/language_quality.py +377 -377
- package/standard-tools/language-guard/translation_guard.py +916 -916
|
@@ -1,515 +1,515 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID,
|
|
3
|
-
sign as cryptoSign, verify as cryptoVerify
|
|
4
|
-
} from "node:crypto";
|
|
5
|
-
import {
|
|
6
|
-
chmod, lstat, mkdir, open, readFile, stat, unlink, writeFile
|
|
7
|
-
} from "node:fs/promises";
|
|
8
|
-
import { dirname, join, resolve } from "node:path";
|
|
9
|
-
import { buildCatalog } from "./catalog.js";
|
|
10
|
-
import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
|
|
11
|
-
import { projectId, projectStateDir, statePathIsScanExcluded, stateRoot } from "./paths.js";
|
|
12
|
-
|
|
13
|
-
const AUTH_CONFIRMATION = "local-share-confirmed";
|
|
14
|
-
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
15
|
-
const DIGEST_RE = /^[a-f0-9]{64}$/;
|
|
16
|
-
const SIGNATURE_RE = /^[A-Za-z0-9+/]{80,96}={0,2}$/;
|
|
17
|
-
const MAX_AUTH_BYTES = 2 * 1024 * 1024;
|
|
18
|
-
const MAX_PUBLIC_IDENTITY_BYTES = 16 * 1024;
|
|
19
|
-
const SECRET_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|gh[opusu])_[A-Za-z0-9_-]{20,}\b|\bBearer\s+[A-Za-z0-9._~+/-]{20,}|\b(?:api[-_ ]?key|token|password|secret)\s*[:=]\s*\S{8,}/i;
|
|
20
|
-
|
|
21
|
-
function canonical(value) {
|
|
22
|
-
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
23
|
-
if (value && typeof value === "object") {
|
|
24
|
-
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
|
25
|
-
}
|
|
26
|
-
return JSON.stringify(value);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function digest(value) {
|
|
30
|
-
return createHash("sha256").update(canonical(value)).digest("hex");
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function exactKeys(value, expected) {
|
|
34
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
35
|
-
const actual = Object.keys(value).sort();
|
|
36
|
-
const wanted = [...expected].sort();
|
|
37
|
-
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function validDate(value) {
|
|
41
|
-
return typeof value === "string" && value.length > 0 && Number.isFinite(new Date(value).getTime());
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function isoDate(value, field = "date") {
|
|
45
|
-
const parsed = value instanceof Date ? value : new Date(value);
|
|
46
|
-
if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
|
|
47
|
-
return parsed.toISOString();
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function requireConfirmation(confirmation) {
|
|
51
|
-
if (confirmation !== AUTH_CONFIRMATION) throw new Error("signer and trust changes require explicit local owner confirmation");
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function emptyRegistry() {
|
|
55
|
-
return { schema: "agentspine.signers/v1", signers: [], history: [] };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function emptyTrust(root) {
|
|
59
|
-
return { schema: "agentspine.sharing-trust/v1", root, records: [], history: [] };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function publicIdentityBody({ signerId, publicKey, createdAt }) {
|
|
63
|
-
const keyBytes = Buffer.from(publicKey, "base64");
|
|
64
|
-
return {
|
|
65
|
-
schema: "agentspine.signer/v1",
|
|
66
|
-
signerId,
|
|
67
|
-
keyId: `ed25519:${createHash("sha256").update(keyBytes).digest("hex")}`,
|
|
68
|
-
algorithm: "Ed25519",
|
|
69
|
-
publicKey,
|
|
70
|
-
createdAt,
|
|
71
|
-
authority: "context-only"
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function validatePublicIdentity(identity) {
|
|
76
|
-
if (!identity || typeof identity !== "object" || Array.isArray(identity)
|
|
77
|
-
|| !exactKeys(identity, ["schema", "signerId", "keyId", "algorithm", "publicKey", "createdAt", "authority", "digest"])
|
|
78
|
-
|| identity.schema !== "agentspine.signer/v1" || !ID_RE.test(identity.signerId || "")
|
|
79
|
-
|| !ID_RE.test(identity.keyId || "") || identity.algorithm !== "Ed25519"
|
|
80
|
-
|| typeof identity.publicKey !== "string" || identity.publicKey.length > 1024
|
|
81
|
-
|| !validDate(identity.createdAt) || identity.authority !== "context-only"
|
|
82
|
-
|| !DIGEST_RE.test(identity.digest || "")) {
|
|
83
|
-
throw new Error("public signing identity is invalid");
|
|
84
|
-
}
|
|
85
|
-
let key;
|
|
86
|
-
try {
|
|
87
|
-
key = createPublicKey({ key: Buffer.from(identity.publicKey, "base64"), format: "der", type: "spki" });
|
|
88
|
-
} catch {
|
|
89
|
-
throw new Error("public signing identity contains an invalid Ed25519 key");
|
|
90
|
-
}
|
|
91
|
-
if (key.asymmetricKeyType !== "ed25519") throw new Error("public signing identity must use Ed25519");
|
|
92
|
-
const body = { ...identity };
|
|
93
|
-
delete body.digest;
|
|
94
|
-
const expected = publicIdentityBody({ signerId: identity.signerId, publicKey: identity.publicKey, createdAt: identity.createdAt });
|
|
95
|
-
if (identity.keyId !== expected.keyId || identity.digest !== digest(body)) {
|
|
96
|
-
throw new Error("public signing identity failed its fingerprint or integrity check");
|
|
97
|
-
}
|
|
98
|
-
return identity;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function registryFindings(registry) {
|
|
102
|
-
const findings = [];
|
|
103
|
-
if (!exactKeys(registry, ["schema", "signers", "history"])
|
|
104
|
-
|| registry.schema !== "agentspine.signers/v1" || !Array.isArray(registry.signers)
|
|
105
|
-
|| !Array.isArray(registry.history)) return ["invalid-signer-registry"];
|
|
106
|
-
const ids = new Set();
|
|
107
|
-
const keys = new Set();
|
|
108
|
-
for (const record of registry.signers) {
|
|
109
|
-
try { validatePublicIdentity(record.publicIdentity); } catch { findings.push(`invalid-signer:${record?.signerId || "unknown"}`); }
|
|
110
|
-
if (!exactKeys(record, ["signerId", "keyId", "publicIdentity", "createdAt", "authority"])
|
|
111
|
-
|| record.signerId !== record.publicIdentity?.signerId || record.keyId !== record.publicIdentity?.keyId
|
|
112
|
-
|| record.createdAt !== record.publicIdentity?.createdAt || record.authority !== "context-only") {
|
|
113
|
-
findings.push(`invalid-signer-record:${record?.signerId || "unknown"}`);
|
|
114
|
-
}
|
|
115
|
-
if (ids.has(record.signerId)) findings.push(`duplicate-signer:${record.signerId}`);
|
|
116
|
-
if (keys.has(record.keyId)) findings.push(`duplicate-signer-key:${record.keyId}`);
|
|
117
|
-
ids.add(record.signerId); keys.add(record.keyId);
|
|
118
|
-
}
|
|
119
|
-
for (const entry of registry.history) {
|
|
120
|
-
if (!exactKeys(entry, ["kind", "signerId", "keyId", "replacedAt", "value", "authority"])
|
|
121
|
-
|| entry.kind !== "signer-rotation" || entry.signerId !== entry.value?.signerId
|
|
122
|
-
|| entry.keyId !== entry.value?.keyId || !validDate(entry.replacedAt)
|
|
123
|
-
|| entry.authority !== "context-only") findings.push(`invalid-signer-history:${entry?.signerId || "unknown"}`);
|
|
124
|
-
try { validatePublicIdentity(entry.value); } catch { findings.push(`invalid-retired-signer:${entry?.signerId || "unknown"}`); }
|
|
125
|
-
}
|
|
126
|
-
return [...new Set(findings)];
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export function trustFindings(trust) {
|
|
130
|
-
const findings = [];
|
|
131
|
-
if (!exactKeys(trust, ["schema", "root", "records", "history"])
|
|
132
|
-
|| trust.schema !== "agentspine.sharing-trust/v1" || typeof trust.root !== "string"
|
|
133
|
-
|| !Array.isArray(trust.records) || !Array.isArray(trust.history)) return ["invalid-sharing-trust"];
|
|
134
|
-
const keys = new Set();
|
|
135
|
-
for (const record of trust.records) {
|
|
136
|
-
try { validatePublicIdentity(record.publicIdentity); } catch { findings.push(`invalid-trusted-signer:${record?.keyId || "unknown"}`); }
|
|
137
|
-
if (!validTrustRecord(record)) findings.push(`invalid-trust-record:${record?.keyId || "unknown"}`);
|
|
138
|
-
if (keys.has(record.keyId)) findings.push(`duplicate-trusted-key:${record.keyId}`);
|
|
139
|
-
keys.add(record.keyId);
|
|
140
|
-
}
|
|
141
|
-
for (const entry of trust.history) {
|
|
142
|
-
if (!exactKeys(entry, ["kind", "keyId", "changedAt", "value", "authority"])
|
|
143
|
-
|| entry.kind !== "trust-record" || entry.keyId !== entry.value?.keyId
|
|
144
|
-
|| !validDate(entry.changedAt) || entry.authority !== "context-only") {
|
|
145
|
-
findings.push(`invalid-trust-history:${entry?.keyId || "unknown"}`);
|
|
146
|
-
}
|
|
147
|
-
if (!validTrustRecord(entry.value)) findings.push(`invalid-historical-trust:${entry?.keyId || "unknown"}`);
|
|
148
|
-
try { validatePublicIdentity(entry.value?.publicIdentity); } catch { findings.push(`invalid-historical-trusted-signer:${entry?.keyId || "unknown"}`); }
|
|
149
|
-
}
|
|
150
|
-
return [...new Set(findings)];
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function validTrustRecord(record) {
|
|
154
|
-
if (!exactKeys(record, ["signerId", "keyId", "publicIdentity", "status", "addedAt", "revokedAt", "reason", "confirmation", "authority"])
|
|
155
|
-
|| record.signerId !== record.publicIdentity?.signerId || record.keyId !== record.publicIdentity?.keyId
|
|
156
|
-
|| !new Set(["trusted", "revoked"]).has(record.status) || !validDate(record.addedAt)
|
|
157
|
-
|| record.confirmation !== "local-owner" || record.authority !== "context-only") return false;
|
|
158
|
-
if (record.status === "trusted") return record.revokedAt === null && record.reason === null;
|
|
159
|
-
return validDate(record.revokedAt) && typeof record.reason === "string" && Boolean(record.reason.trim());
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
async function authPaths(root, providedCatalog = null, { allowInside = false, create = true } = {}) {
|
|
163
|
-
const catalog = providedCatalog || await buildCatalog(root);
|
|
164
|
-
const globalRoot = resolve(stateRoot());
|
|
165
|
-
const signerDirectory = join(globalRoot, "signers");
|
|
166
|
-
if (!allowInside && !statePathIsScanExcluded(catalog, signerDirectory)) {
|
|
167
|
-
throw new Error("signing identity state must remain outside the scanned project or in an excluded home-state root");
|
|
168
|
-
}
|
|
169
|
-
if (create) await mkdir(join(signerDirectory, "private"), { recursive: true, mode: 0o700 });
|
|
170
|
-
const projectDirectory = create
|
|
171
|
-
? await projectStateDir(catalog.root)
|
|
172
|
-
: join(globalRoot, "projects", projectId(catalog.root));
|
|
173
|
-
return {
|
|
174
|
-
catalog,
|
|
175
|
-
signerDirectory,
|
|
176
|
-
registryPath: join(signerDirectory, "registry.json"),
|
|
177
|
-
trustPath: join(projectDirectory, "sharing-trust.json")
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
async function readBoundedJson(path, fallback, normalize) {
|
|
182
|
-
try {
|
|
183
|
-
const metadata = await stat(path);
|
|
184
|
-
if (metadata.size > MAX_AUTH_BYTES) throw new Error("authentication state exceeds the 2 MiB read limit");
|
|
185
|
-
const value = JSON.parse(await readFile(path, "utf8"));
|
|
186
|
-
return normalize(value);
|
|
187
|
-
} catch (error) {
|
|
188
|
-
if (error.code !== "ENOENT") throw error;
|
|
189
|
-
return fallback();
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
async function atomicWrite(path, value, mode = 0o600) {
|
|
194
|
-
const content = `${JSON.stringify(value, null, 2)}\n`;
|
|
195
|
-
if (Buffer.byteLength(content) > MAX_AUTH_BYTES) throw new Error("authentication state exceeds 2 MiB");
|
|
196
|
-
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
197
|
-
await writeFile(temporary, content, { mode });
|
|
198
|
-
await replaceFileWithRetry(temporary, path);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async function acquireLock(path) {
|
|
202
|
-
const lockPath = `${path}.lock`;
|
|
203
|
-
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
204
|
-
try {
|
|
205
|
-
return { handle: await open(lockPath, "wx", 0o600), lockPath };
|
|
206
|
-
} catch (error) {
|
|
207
|
-
if (!isFileLockContention(error)) throw error;
|
|
208
|
-
try {
|
|
209
|
-
const metadata = await stat(lockPath);
|
|
210
|
-
if (Date.now() - metadata.mtimeMs > 15000) await unlink(lockPath);
|
|
211
|
-
} catch (lockError) {
|
|
212
|
-
if (lockError.code !== "ENOENT") throw lockError;
|
|
213
|
-
}
|
|
214
|
-
await new Promise((resolvePromise) => setTimeout(resolvePromise, 25));
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
throw new Error("authentication state is busy; retry shortly");
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
async function mutate(path, read, findings, operation) {
|
|
221
|
-
const { handle, lockPath } = await acquireLock(path);
|
|
222
|
-
try {
|
|
223
|
-
const state = await read();
|
|
224
|
-
const before = findings(state);
|
|
225
|
-
if (before.length) throw new Error(`authentication state failed closed: ${before.join(", ")}`);
|
|
226
|
-
const result = await operation(state);
|
|
227
|
-
const after = findings(state);
|
|
228
|
-
if (after.length) throw new Error(`authentication mutation failed closed: ${after.join(", ")}`);
|
|
229
|
-
await atomicWrite(path, state);
|
|
230
|
-
return result;
|
|
231
|
-
} finally {
|
|
232
|
-
await handle.close();
|
|
233
|
-
await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function normalizeRegistry(value) {
|
|
238
|
-
const findings = registryFindings(value);
|
|
239
|
-
if (findings.length) throw new Error(`signer registry is invalid: ${findings.join(", ")}`);
|
|
240
|
-
return value;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function normalizeTrust(value, root) {
|
|
244
|
-
if (value?.root !== root) throw new Error("sharing trust state belongs to another project");
|
|
245
|
-
const findings = trustFindings(value);
|
|
246
|
-
if (findings.length) throw new Error(`sharing trust state is invalid: ${findings.join(", ")}`);
|
|
247
|
-
return value;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function privatePath(directory, keyId) {
|
|
251
|
-
return join(directory, "private", `${createHash("sha256").update(keyId).digest("hex")}.pem`);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
export async function generateSigningIdentity({
|
|
255
|
-
root = process.cwd(), signerId, rotate = false, publicOut = null,
|
|
256
|
-
confirmation, now = new Date()
|
|
257
|
-
}) {
|
|
258
|
-
requireConfirmation(confirmation);
|
|
259
|
-
if (!ID_RE.test(signerId || "")) throw new Error("signerId must be a stable, whitespace-free identifier");
|
|
260
|
-
const publicTarget = publicOut ? resolve(publicOut) : null;
|
|
261
|
-
if (publicTarget) {
|
|
262
|
-
try {
|
|
263
|
-
await lstat(publicTarget);
|
|
264
|
-
throw new Error(`public identity output already exists: ${publicTarget}`);
|
|
265
|
-
} catch (error) {
|
|
266
|
-
if (error.code !== "ENOENT") throw error;
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
const paths = await authPaths(root);
|
|
270
|
-
const createdAt = isoDate(now, "now");
|
|
271
|
-
const result = await mutate(
|
|
272
|
-
paths.registryPath,
|
|
273
|
-
() => readBoundedJson(paths.registryPath, emptyRegistry, normalizeRegistry),
|
|
274
|
-
registryFindings,
|
|
275
|
-
async (registry) => {
|
|
276
|
-
const existing = registry.signers.find((record) => record.signerId === signerId);
|
|
277
|
-
if (existing && !rotate) throw new Error(`signer already exists: ${signerId}; use explicit rotation`);
|
|
278
|
-
const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
|
|
279
|
-
publicKeyEncoding: { format: "der", type: "spki" },
|
|
280
|
-
privateKeyEncoding: { format: "pem", type: "pkcs8" }
|
|
281
|
-
});
|
|
282
|
-
const body = publicIdentityBody({ signerId, publicKey: publicKey.toString("base64"), createdAt });
|
|
283
|
-
const publicIdentity = { ...body, digest: digest(body) };
|
|
284
|
-
validatePublicIdentity(publicIdentity);
|
|
285
|
-
const path = privatePath(paths.signerDirectory, publicIdentity.keyId);
|
|
286
|
-
await writeFile(path, privateKey, { flag: "wx", mode: 0o600 });
|
|
287
|
-
await chmod(path, 0o600);
|
|
288
|
-
if (existing) {
|
|
289
|
-
registry.history.push({
|
|
290
|
-
kind: "signer-rotation", signerId, keyId: existing.keyId, replacedAt: createdAt,
|
|
291
|
-
value: existing.publicIdentity, authority: "context-only"
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
const record = { signerId, keyId: publicIdentity.keyId, publicIdentity, createdAt, authority: "context-only" };
|
|
295
|
-
registry.signers = [...registry.signers.filter((item) => item.signerId !== signerId), record]
|
|
296
|
-
.sort((a, b) => a.signerId.localeCompare(b.signerId));
|
|
297
|
-
return { signerId, keyId: record.keyId, publicIdentity, retiredKeyId: existing?.keyId || null };
|
|
298
|
-
}
|
|
299
|
-
);
|
|
300
|
-
if (result.retiredKeyId) await unlink(privatePath(paths.signerDirectory, result.retiredKeyId)).catch((error) => {
|
|
301
|
-
if (error.code !== "ENOENT") throw error;
|
|
302
|
-
});
|
|
303
|
-
if (publicOut) {
|
|
304
|
-
await mkdir(dirname(publicTarget), { recursive: true, mode: 0o700 });
|
|
305
|
-
await writeFile(publicTarget, `${JSON.stringify(result.publicIdentity, null, 2)}\n`, { flag: "wx", mode: 0o644 });
|
|
306
|
-
}
|
|
307
|
-
return { ...result, publicOut: publicTarget, authority: "context-only" };
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
export async function listSigningIdentities({ root = process.cwd() } = {}) {
|
|
311
|
-
const paths = await authPaths(root);
|
|
312
|
-
const registry = await readBoundedJson(paths.registryPath, emptyRegistry, normalizeRegistry);
|
|
313
|
-
return {
|
|
314
|
-
schema: registry.schema,
|
|
315
|
-
signers: registry.signers.map((record) => record.publicIdentity),
|
|
316
|
-
retired: registry.history.map((entry) => entry.value),
|
|
317
|
-
authority: "context-only"
|
|
318
|
-
};
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
export async function inspectSignerRegistry(root = process.cwd(), providedCatalog = null) {
|
|
322
|
-
const paths = await authPaths(root, providedCatalog, { allowInside: true, create: false });
|
|
323
|
-
try {
|
|
324
|
-
const registry = await readBoundedJson(paths.registryPath, emptyRegistry, normalizeRegistry);
|
|
325
|
-
const errors = [];
|
|
326
|
-
for (const record of registry.signers) {
|
|
327
|
-
const path = privatePath(paths.signerDirectory, record.keyId);
|
|
328
|
-
try {
|
|
329
|
-
const metadata = await lstat(path);
|
|
330
|
-
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_PUBLIC_IDENTITY_BYTES) {
|
|
331
|
-
errors.push(`invalid-private-key-file:${record.signerId}`);
|
|
332
|
-
continue;
|
|
333
|
-
}
|
|
334
|
-
if (process.platform !== "win32" && (metadata.mode & 0o077) !== 0) errors.push(`unsafe-private-key-mode:${record.signerId}`);
|
|
335
|
-
const privateKey = createPrivateKey(await readFile(path, "utf8"));
|
|
336
|
-
const publicKey = createPublicKey(privateKey).export({ format: "der", type: "spki" }).toString("base64");
|
|
337
|
-
if (privateKey.asymmetricKeyType !== "ed25519" || publicKey !== record.publicIdentity.publicKey) {
|
|
338
|
-
errors.push(`private-public-key-mismatch:${record.signerId}`);
|
|
339
|
-
}
|
|
340
|
-
} catch (error) {
|
|
341
|
-
errors.push(`unreadable-private-key:${record.signerId}:${error.code || "invalid"}`);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
return { registry, registryPath: paths.registryPath, signerDirectory: paths.signerDirectory, errors, catalog: paths.catalog };
|
|
345
|
-
} catch (error) {
|
|
346
|
-
return {
|
|
347
|
-
registry: emptyRegistry(), registryPath: paths.registryPath, signerDirectory: paths.signerDirectory,
|
|
348
|
-
errors: [`unreadable-registry:${error.message}`], catalog: paths.catalog
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
async function activeIdentity(root, signerId) {
|
|
354
|
-
const paths = await authPaths(root);
|
|
355
|
-
const registry = await readBoundedJson(paths.registryPath, emptyRegistry, normalizeRegistry);
|
|
356
|
-
const record = registry.signers.find((item) => item.signerId === signerId);
|
|
357
|
-
if (!record) throw new Error(`unknown local signer: ${signerId}`);
|
|
358
|
-
const path = privatePath(paths.signerDirectory, record.keyId);
|
|
359
|
-
const metadata = await lstat(path);
|
|
360
|
-
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("signing private key must be a regular file");
|
|
361
|
-
if (process.platform !== "win32" && (metadata.mode & 0o077) !== 0) throw new Error("signing private key permissions are too broad");
|
|
362
|
-
const privateKey = createPrivateKey(await readFile(path, "utf8"));
|
|
363
|
-
if (privateKey.asymmetricKeyType !== "ed25519") throw new Error("signing private key is not Ed25519");
|
|
364
|
-
return { publicIdentity: record.publicIdentity, privateKey };
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
export async function signEnvelope({ root = process.cwd(), signerId, kind, payload, now = new Date() }) {
|
|
368
|
-
if (!new Set(["manifest", "event"]).has(kind)) throw new Error("signed envelope kind must be manifest or event");
|
|
369
|
-
const identity = await activeIdentity(root, signerId);
|
|
370
|
-
const body = {
|
|
371
|
-
schema: "agentspine.signed-envelope/v1",
|
|
372
|
-
kind,
|
|
373
|
-
signer: identity.publicIdentity,
|
|
374
|
-
payload,
|
|
375
|
-
signedAt: isoDate(now, "now"),
|
|
376
|
-
authority: "context-only"
|
|
377
|
-
};
|
|
378
|
-
const signature = cryptoSign(null, Buffer.from(canonical(body)), identity.privateKey).toString("base64");
|
|
379
|
-
return { ...body, signature };
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
export function verifyEnvelope(envelope, expectedKind = null) {
|
|
383
|
-
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)
|
|
384
|
-
|| !exactKeys(envelope, ["schema", "kind", "signer", "payload", "signedAt", "authority", "signature"])
|
|
385
|
-
|| envelope.schema !== "agentspine.signed-envelope/v1"
|
|
386
|
-
|| !new Set(["manifest", "event"]).has(envelope.kind)
|
|
387
|
-
|| (expectedKind !== null && envelope.kind !== expectedKind)
|
|
388
|
-
|| !validDate(envelope.signedAt) || envelope.authority !== "context-only"
|
|
389
|
-
|| !SIGNATURE_RE.test(envelope.signature || "")) {
|
|
390
|
-
throw new Error("signed envelope structure is invalid");
|
|
391
|
-
}
|
|
392
|
-
validatePublicIdentity(envelope.signer);
|
|
393
|
-
const body = { ...envelope };
|
|
394
|
-
delete body.signature;
|
|
395
|
-
const key = createPublicKey({ key: Buffer.from(envelope.signer.publicKey, "base64"), format: "der", type: "spki" });
|
|
396
|
-
if (!cryptoVerify(null, Buffer.from(canonical(body)), key, Buffer.from(envelope.signature, "base64"))) {
|
|
397
|
-
throw new Error("signed envelope signature is invalid");
|
|
398
|
-
}
|
|
399
|
-
return {
|
|
400
|
-
payload: envelope.payload,
|
|
401
|
-
authentication: {
|
|
402
|
-
mode: "signed",
|
|
403
|
-
signerId: envelope.signer.signerId,
|
|
404
|
-
keyId: envelope.signer.keyId,
|
|
405
|
-
signedAt: envelope.signedAt,
|
|
406
|
-
verifiedAt: null,
|
|
407
|
-
signature: envelope.signature,
|
|
408
|
-
authority: "context-only"
|
|
409
|
-
},
|
|
410
|
-
publicIdentity: envelope.signer
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
export async function loadTrust(root = process.cwd(), providedCatalog = null) {
|
|
415
|
-
const paths = await authPaths(root, providedCatalog);
|
|
416
|
-
const trust = await readBoundedJson(
|
|
417
|
-
paths.trustPath,
|
|
418
|
-
() => emptyTrust(paths.catalog.root),
|
|
419
|
-
(value) => normalizeTrust(value, paths.catalog.root)
|
|
420
|
-
);
|
|
421
|
-
return { trust, trustPath: paths.trustPath, catalog: paths.catalog };
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
export async function inspectTrust(root = process.cwd(), providedCatalog = null) {
|
|
425
|
-
const paths = await authPaths(root, providedCatalog, { allowInside: true, create: false });
|
|
426
|
-
try {
|
|
427
|
-
const trust = await readBoundedJson(
|
|
428
|
-
paths.trustPath, () => emptyTrust(paths.catalog.root),
|
|
429
|
-
(value) => normalizeTrust(value, paths.catalog.root)
|
|
430
|
-
);
|
|
431
|
-
return { trust, trustPath: paths.trustPath, catalog: paths.catalog, error: null };
|
|
432
|
-
} catch (error) {
|
|
433
|
-
return { trust: emptyTrust(paths.catalog.root), trustPath: paths.trustPath, catalog: paths.catalog, error: error.message };
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
async function trustMutation(root, operation) {
|
|
438
|
-
const paths = await authPaths(root);
|
|
439
|
-
return mutate(
|
|
440
|
-
paths.trustPath,
|
|
441
|
-
() => readBoundedJson(paths.trustPath, () => emptyTrust(paths.catalog.root), (value) => normalizeTrust(value, paths.catalog.root)),
|
|
442
|
-
trustFindings,
|
|
443
|
-
(trust) => operation(trust, paths)
|
|
444
|
-
);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
export async function trustSigner({ root = process.cwd(), publicIdentityPath, confirmation, now = new Date() }) {
|
|
448
|
-
requireConfirmation(confirmation);
|
|
449
|
-
if (!publicIdentityPath || typeof publicIdentityPath !== "string") throw new Error("public identity path is required");
|
|
450
|
-
const target = resolve(publicIdentityPath);
|
|
451
|
-
const metadata = await lstat(target);
|
|
452
|
-
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_PUBLIC_IDENTITY_BYTES) {
|
|
453
|
-
throw new Error("public identity must be a regular JSON file smaller than 16 KiB");
|
|
454
|
-
}
|
|
455
|
-
const publicIdentity = validatePublicIdentity(JSON.parse(await readFile(target, "utf8")));
|
|
456
|
-
const addedAt = isoDate(now, "now");
|
|
457
|
-
return trustMutation(root, (trust, paths) => {
|
|
458
|
-
const existing = trust.records.find((record) => record.keyId === publicIdentity.keyId);
|
|
459
|
-
if (existing?.status === "trusted") return { created: false, record: existing, trustPath: paths.trustPath };
|
|
460
|
-
if (existing?.status === "revoked") throw new Error("a revoked key cannot be silently trusted again; import a rotated key");
|
|
461
|
-
const record = {
|
|
462
|
-
signerId: publicIdentity.signerId, keyId: publicIdentity.keyId, publicIdentity,
|
|
463
|
-
status: "trusted", addedAt, revokedAt: null, reason: null,
|
|
464
|
-
confirmation: "local-owner", authority: "context-only"
|
|
465
|
-
};
|
|
466
|
-
trust.records.push(record);
|
|
467
|
-
trust.records.sort((a, b) => a.signerId.localeCompare(b.signerId) || a.keyId.localeCompare(b.keyId));
|
|
468
|
-
return { created: true, record, trustPath: paths.trustPath };
|
|
469
|
-
});
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
export async function revokeTrustedSigner({ root = process.cwd(), keyId, reason, confirmation, now = new Date() }) {
|
|
473
|
-
requireConfirmation(confirmation);
|
|
474
|
-
if (!ID_RE.test(keyId || "")) throw new Error("keyId is required");
|
|
475
|
-
if (!reason || typeof reason !== "string" || !reason.trim()) throw new Error("revocation reason is required");
|
|
476
|
-
if (SECRET_RE.test(reason)) throw new Error("revocation reason appears to contain a secret");
|
|
477
|
-
const revokedAt = isoDate(now, "now");
|
|
478
|
-
return trustMutation(root, (trust, paths) => {
|
|
479
|
-
const record = trust.records.find((item) => item.keyId === keyId);
|
|
480
|
-
if (!record) throw new Error(`unknown trusted key: ${keyId}`);
|
|
481
|
-
if (record.status === "revoked") return { changed: false, record, trustPath: paths.trustPath };
|
|
482
|
-
trust.history.push({ kind: "trust-record", keyId, changedAt: revokedAt, value: { ...record }, authority: "context-only" });
|
|
483
|
-
const revoked = { ...record, status: "revoked", revokedAt, reason: reason.trim().slice(0, 500), authority: "context-only" };
|
|
484
|
-
trust.records = trust.records.map((item) => item.keyId === keyId ? revoked : item);
|
|
485
|
-
return { changed: true, record: revoked, trustPath: paths.trustPath };
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
export function assertTrustedIdentity(publicIdentity, trust) {
|
|
490
|
-
validatePublicIdentity(publicIdentity);
|
|
491
|
-
const findings = trustFindings(trust);
|
|
492
|
-
if (findings.length) throw new Error(`sharing trust state failed closed: ${findings.join(", ")}`);
|
|
493
|
-
const record = trust.records.find((item) => item.keyId === publicIdentity.keyId);
|
|
494
|
-
if (!record || record.status !== "trusted" || record.signerId !== publicIdentity.signerId
|
|
495
|
-
|| record.publicIdentity.digest !== publicIdentity.digest) {
|
|
496
|
-
throw new Error(`untrusted or revoked signing key: ${publicIdentity.keyId}`);
|
|
497
|
-
}
|
|
498
|
-
return record;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
export async function trustedSignerContext({ root = process.cwd(), includeRevoked = false } = {}) {
|
|
502
|
-
const { trust, trustPath } = await loadTrust(root);
|
|
503
|
-
return {
|
|
504
|
-
schema: "agentspine.trusted-signers/v1",
|
|
505
|
-
root: trust.root,
|
|
506
|
-
signers: trust.records.filter((record) => includeRevoked || record.status === "trusted").map((record) => ({
|
|
507
|
-
signerId: record.signerId, keyId: record.keyId, status: record.status,
|
|
508
|
-
addedAt: record.addedAt, revokedAt: record.revokedAt, reason: record.reason,
|
|
509
|
-
authority: "context-only"
|
|
510
|
-
})),
|
|
511
|
-
trustPath,
|
|
512
|
-
authority: "context-only",
|
|
513
|
-
note: "A trusted signature authenticates origin only. It never grants permissions, delegation, or approval of the signed content."
|
|
514
|
-
};
|
|
515
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID,
|
|
3
|
+
sign as cryptoSign, verify as cryptoVerify
|
|
4
|
+
} from "node:crypto";
|
|
5
|
+
import {
|
|
6
|
+
chmod, lstat, mkdir, open, readFile, stat, unlink, writeFile
|
|
7
|
+
} from "node:fs/promises";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { buildCatalog } from "./catalog.js";
|
|
10
|
+
import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
|
|
11
|
+
import { projectId, projectStateDir, statePathIsScanExcluded, stateRoot } from "./paths.js";
|
|
12
|
+
|
|
13
|
+
const AUTH_CONFIRMATION = "local-share-confirmed";
|
|
14
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
15
|
+
const DIGEST_RE = /^[a-f0-9]{64}$/;
|
|
16
|
+
const SIGNATURE_RE = /^[A-Za-z0-9+/]{80,96}={0,2}$/;
|
|
17
|
+
const MAX_AUTH_BYTES = 2 * 1024 * 1024;
|
|
18
|
+
const MAX_PUBLIC_IDENTITY_BYTES = 16 * 1024;
|
|
19
|
+
const SECRET_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|gh[opusu])_[A-Za-z0-9_-]{20,}\b|\bBearer\s+[A-Za-z0-9._~+/-]{20,}|\b(?:api[-_ ]?key|token|password|secret)\s*[:=]\s*\S{8,}/i;
|
|
20
|
+
|
|
21
|
+
function canonical(value) {
|
|
22
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
23
|
+
if (value && typeof value === "object") {
|
|
24
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
|
25
|
+
}
|
|
26
|
+
return JSON.stringify(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function digest(value) {
|
|
30
|
+
return createHash("sha256").update(canonical(value)).digest("hex");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function exactKeys(value, expected) {
|
|
34
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
35
|
+
const actual = Object.keys(value).sort();
|
|
36
|
+
const wanted = [...expected].sort();
|
|
37
|
+
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function validDate(value) {
|
|
41
|
+
return typeof value === "string" && value.length > 0 && Number.isFinite(new Date(value).getTime());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isoDate(value, field = "date") {
|
|
45
|
+
const parsed = value instanceof Date ? value : new Date(value);
|
|
46
|
+
if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
|
|
47
|
+
return parsed.toISOString();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function requireConfirmation(confirmation) {
|
|
51
|
+
if (confirmation !== AUTH_CONFIRMATION) throw new Error("signer and trust changes require explicit local owner confirmation");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function emptyRegistry() {
|
|
55
|
+
return { schema: "agentspine.signers/v1", signers: [], history: [] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function emptyTrust(root) {
|
|
59
|
+
return { schema: "agentspine.sharing-trust/v1", root, records: [], history: [] };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function publicIdentityBody({ signerId, publicKey, createdAt }) {
|
|
63
|
+
const keyBytes = Buffer.from(publicKey, "base64");
|
|
64
|
+
return {
|
|
65
|
+
schema: "agentspine.signer/v1",
|
|
66
|
+
signerId,
|
|
67
|
+
keyId: `ed25519:${createHash("sha256").update(keyBytes).digest("hex")}`,
|
|
68
|
+
algorithm: "Ed25519",
|
|
69
|
+
publicKey,
|
|
70
|
+
createdAt,
|
|
71
|
+
authority: "context-only"
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function validatePublicIdentity(identity) {
|
|
76
|
+
if (!identity || typeof identity !== "object" || Array.isArray(identity)
|
|
77
|
+
|| !exactKeys(identity, ["schema", "signerId", "keyId", "algorithm", "publicKey", "createdAt", "authority", "digest"])
|
|
78
|
+
|| identity.schema !== "agentspine.signer/v1" || !ID_RE.test(identity.signerId || "")
|
|
79
|
+
|| !ID_RE.test(identity.keyId || "") || identity.algorithm !== "Ed25519"
|
|
80
|
+
|| typeof identity.publicKey !== "string" || identity.publicKey.length > 1024
|
|
81
|
+
|| !validDate(identity.createdAt) || identity.authority !== "context-only"
|
|
82
|
+
|| !DIGEST_RE.test(identity.digest || "")) {
|
|
83
|
+
throw new Error("public signing identity is invalid");
|
|
84
|
+
}
|
|
85
|
+
let key;
|
|
86
|
+
try {
|
|
87
|
+
key = createPublicKey({ key: Buffer.from(identity.publicKey, "base64"), format: "der", type: "spki" });
|
|
88
|
+
} catch {
|
|
89
|
+
throw new Error("public signing identity contains an invalid Ed25519 key");
|
|
90
|
+
}
|
|
91
|
+
if (key.asymmetricKeyType !== "ed25519") throw new Error("public signing identity must use Ed25519");
|
|
92
|
+
const body = { ...identity };
|
|
93
|
+
delete body.digest;
|
|
94
|
+
const expected = publicIdentityBody({ signerId: identity.signerId, publicKey: identity.publicKey, createdAt: identity.createdAt });
|
|
95
|
+
if (identity.keyId !== expected.keyId || identity.digest !== digest(body)) {
|
|
96
|
+
throw new Error("public signing identity failed its fingerprint or integrity check");
|
|
97
|
+
}
|
|
98
|
+
return identity;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function registryFindings(registry) {
|
|
102
|
+
const findings = [];
|
|
103
|
+
if (!exactKeys(registry, ["schema", "signers", "history"])
|
|
104
|
+
|| registry.schema !== "agentspine.signers/v1" || !Array.isArray(registry.signers)
|
|
105
|
+
|| !Array.isArray(registry.history)) return ["invalid-signer-registry"];
|
|
106
|
+
const ids = new Set();
|
|
107
|
+
const keys = new Set();
|
|
108
|
+
for (const record of registry.signers) {
|
|
109
|
+
try { validatePublicIdentity(record.publicIdentity); } catch { findings.push(`invalid-signer:${record?.signerId || "unknown"}`); }
|
|
110
|
+
if (!exactKeys(record, ["signerId", "keyId", "publicIdentity", "createdAt", "authority"])
|
|
111
|
+
|| record.signerId !== record.publicIdentity?.signerId || record.keyId !== record.publicIdentity?.keyId
|
|
112
|
+
|| record.createdAt !== record.publicIdentity?.createdAt || record.authority !== "context-only") {
|
|
113
|
+
findings.push(`invalid-signer-record:${record?.signerId || "unknown"}`);
|
|
114
|
+
}
|
|
115
|
+
if (ids.has(record.signerId)) findings.push(`duplicate-signer:${record.signerId}`);
|
|
116
|
+
if (keys.has(record.keyId)) findings.push(`duplicate-signer-key:${record.keyId}`);
|
|
117
|
+
ids.add(record.signerId); keys.add(record.keyId);
|
|
118
|
+
}
|
|
119
|
+
for (const entry of registry.history) {
|
|
120
|
+
if (!exactKeys(entry, ["kind", "signerId", "keyId", "replacedAt", "value", "authority"])
|
|
121
|
+
|| entry.kind !== "signer-rotation" || entry.signerId !== entry.value?.signerId
|
|
122
|
+
|| entry.keyId !== entry.value?.keyId || !validDate(entry.replacedAt)
|
|
123
|
+
|| entry.authority !== "context-only") findings.push(`invalid-signer-history:${entry?.signerId || "unknown"}`);
|
|
124
|
+
try { validatePublicIdentity(entry.value); } catch { findings.push(`invalid-retired-signer:${entry?.signerId || "unknown"}`); }
|
|
125
|
+
}
|
|
126
|
+
return [...new Set(findings)];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function trustFindings(trust) {
|
|
130
|
+
const findings = [];
|
|
131
|
+
if (!exactKeys(trust, ["schema", "root", "records", "history"])
|
|
132
|
+
|| trust.schema !== "agentspine.sharing-trust/v1" || typeof trust.root !== "string"
|
|
133
|
+
|| !Array.isArray(trust.records) || !Array.isArray(trust.history)) return ["invalid-sharing-trust"];
|
|
134
|
+
const keys = new Set();
|
|
135
|
+
for (const record of trust.records) {
|
|
136
|
+
try { validatePublicIdentity(record.publicIdentity); } catch { findings.push(`invalid-trusted-signer:${record?.keyId || "unknown"}`); }
|
|
137
|
+
if (!validTrustRecord(record)) findings.push(`invalid-trust-record:${record?.keyId || "unknown"}`);
|
|
138
|
+
if (keys.has(record.keyId)) findings.push(`duplicate-trusted-key:${record.keyId}`);
|
|
139
|
+
keys.add(record.keyId);
|
|
140
|
+
}
|
|
141
|
+
for (const entry of trust.history) {
|
|
142
|
+
if (!exactKeys(entry, ["kind", "keyId", "changedAt", "value", "authority"])
|
|
143
|
+
|| entry.kind !== "trust-record" || entry.keyId !== entry.value?.keyId
|
|
144
|
+
|| !validDate(entry.changedAt) || entry.authority !== "context-only") {
|
|
145
|
+
findings.push(`invalid-trust-history:${entry?.keyId || "unknown"}`);
|
|
146
|
+
}
|
|
147
|
+
if (!validTrustRecord(entry.value)) findings.push(`invalid-historical-trust:${entry?.keyId || "unknown"}`);
|
|
148
|
+
try { validatePublicIdentity(entry.value?.publicIdentity); } catch { findings.push(`invalid-historical-trusted-signer:${entry?.keyId || "unknown"}`); }
|
|
149
|
+
}
|
|
150
|
+
return [...new Set(findings)];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function validTrustRecord(record) {
|
|
154
|
+
if (!exactKeys(record, ["signerId", "keyId", "publicIdentity", "status", "addedAt", "revokedAt", "reason", "confirmation", "authority"])
|
|
155
|
+
|| record.signerId !== record.publicIdentity?.signerId || record.keyId !== record.publicIdentity?.keyId
|
|
156
|
+
|| !new Set(["trusted", "revoked"]).has(record.status) || !validDate(record.addedAt)
|
|
157
|
+
|| record.confirmation !== "local-owner" || record.authority !== "context-only") return false;
|
|
158
|
+
if (record.status === "trusted") return record.revokedAt === null && record.reason === null;
|
|
159
|
+
return validDate(record.revokedAt) && typeof record.reason === "string" && Boolean(record.reason.trim());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function authPaths(root, providedCatalog = null, { allowInside = false, create = true } = {}) {
|
|
163
|
+
const catalog = providedCatalog || await buildCatalog(root);
|
|
164
|
+
const globalRoot = resolve(stateRoot());
|
|
165
|
+
const signerDirectory = join(globalRoot, "signers");
|
|
166
|
+
if (!allowInside && !statePathIsScanExcluded(catalog, signerDirectory)) {
|
|
167
|
+
throw new Error("signing identity state must remain outside the scanned project or in an excluded home-state root");
|
|
168
|
+
}
|
|
169
|
+
if (create) await mkdir(join(signerDirectory, "private"), { recursive: true, mode: 0o700 });
|
|
170
|
+
const projectDirectory = create
|
|
171
|
+
? await projectStateDir(catalog.root)
|
|
172
|
+
: join(globalRoot, "projects", projectId(catalog.root));
|
|
173
|
+
return {
|
|
174
|
+
catalog,
|
|
175
|
+
signerDirectory,
|
|
176
|
+
registryPath: join(signerDirectory, "registry.json"),
|
|
177
|
+
trustPath: join(projectDirectory, "sharing-trust.json")
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function readBoundedJson(path, fallback, normalize) {
|
|
182
|
+
try {
|
|
183
|
+
const metadata = await stat(path);
|
|
184
|
+
if (metadata.size > MAX_AUTH_BYTES) throw new Error("authentication state exceeds the 2 MiB read limit");
|
|
185
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
186
|
+
return normalize(value);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error.code !== "ENOENT") throw error;
|
|
189
|
+
return fallback();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function atomicWrite(path, value, mode = 0o600) {
|
|
194
|
+
const content = `${JSON.stringify(value, null, 2)}\n`;
|
|
195
|
+
if (Buffer.byteLength(content) > MAX_AUTH_BYTES) throw new Error("authentication state exceeds 2 MiB");
|
|
196
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
197
|
+
await writeFile(temporary, content, { mode });
|
|
198
|
+
await replaceFileWithRetry(temporary, path);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function acquireLock(path) {
|
|
202
|
+
const lockPath = `${path}.lock`;
|
|
203
|
+
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
204
|
+
try {
|
|
205
|
+
return { handle: await open(lockPath, "wx", 0o600), lockPath };
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (!isFileLockContention(error)) throw error;
|
|
208
|
+
try {
|
|
209
|
+
const metadata = await stat(lockPath);
|
|
210
|
+
if (Date.now() - metadata.mtimeMs > 15000) await unlink(lockPath);
|
|
211
|
+
} catch (lockError) {
|
|
212
|
+
if (lockError.code !== "ENOENT") throw lockError;
|
|
213
|
+
}
|
|
214
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 25));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
throw new Error("authentication state is busy; retry shortly");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function mutate(path, read, findings, operation) {
|
|
221
|
+
const { handle, lockPath } = await acquireLock(path);
|
|
222
|
+
try {
|
|
223
|
+
const state = await read();
|
|
224
|
+
const before = findings(state);
|
|
225
|
+
if (before.length) throw new Error(`authentication state failed closed: ${before.join(", ")}`);
|
|
226
|
+
const result = await operation(state);
|
|
227
|
+
const after = findings(state);
|
|
228
|
+
if (after.length) throw new Error(`authentication mutation failed closed: ${after.join(", ")}`);
|
|
229
|
+
await atomicWrite(path, state);
|
|
230
|
+
return result;
|
|
231
|
+
} finally {
|
|
232
|
+
await handle.close();
|
|
233
|
+
await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function normalizeRegistry(value) {
|
|
238
|
+
const findings = registryFindings(value);
|
|
239
|
+
if (findings.length) throw new Error(`signer registry is invalid: ${findings.join(", ")}`);
|
|
240
|
+
return value;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function normalizeTrust(value, root) {
|
|
244
|
+
if (value?.root !== root) throw new Error("sharing trust state belongs to another project");
|
|
245
|
+
const findings = trustFindings(value);
|
|
246
|
+
if (findings.length) throw new Error(`sharing trust state is invalid: ${findings.join(", ")}`);
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function privatePath(directory, keyId) {
|
|
251
|
+
return join(directory, "private", `${createHash("sha256").update(keyId).digest("hex")}.pem`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function generateSigningIdentity({
|
|
255
|
+
root = process.cwd(), signerId, rotate = false, publicOut = null,
|
|
256
|
+
confirmation, now = new Date()
|
|
257
|
+
}) {
|
|
258
|
+
requireConfirmation(confirmation);
|
|
259
|
+
if (!ID_RE.test(signerId || "")) throw new Error("signerId must be a stable, whitespace-free identifier");
|
|
260
|
+
const publicTarget = publicOut ? resolve(publicOut) : null;
|
|
261
|
+
if (publicTarget) {
|
|
262
|
+
try {
|
|
263
|
+
await lstat(publicTarget);
|
|
264
|
+
throw new Error(`public identity output already exists: ${publicTarget}`);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (error.code !== "ENOENT") throw error;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const paths = await authPaths(root);
|
|
270
|
+
const createdAt = isoDate(now, "now");
|
|
271
|
+
const result = await mutate(
|
|
272
|
+
paths.registryPath,
|
|
273
|
+
() => readBoundedJson(paths.registryPath, emptyRegistry, normalizeRegistry),
|
|
274
|
+
registryFindings,
|
|
275
|
+
async (registry) => {
|
|
276
|
+
const existing = registry.signers.find((record) => record.signerId === signerId);
|
|
277
|
+
if (existing && !rotate) throw new Error(`signer already exists: ${signerId}; use explicit rotation`);
|
|
278
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
|
|
279
|
+
publicKeyEncoding: { format: "der", type: "spki" },
|
|
280
|
+
privateKeyEncoding: { format: "pem", type: "pkcs8" }
|
|
281
|
+
});
|
|
282
|
+
const body = publicIdentityBody({ signerId, publicKey: publicKey.toString("base64"), createdAt });
|
|
283
|
+
const publicIdentity = { ...body, digest: digest(body) };
|
|
284
|
+
validatePublicIdentity(publicIdentity);
|
|
285
|
+
const path = privatePath(paths.signerDirectory, publicIdentity.keyId);
|
|
286
|
+
await writeFile(path, privateKey, { flag: "wx", mode: 0o600 });
|
|
287
|
+
await chmod(path, 0o600);
|
|
288
|
+
if (existing) {
|
|
289
|
+
registry.history.push({
|
|
290
|
+
kind: "signer-rotation", signerId, keyId: existing.keyId, replacedAt: createdAt,
|
|
291
|
+
value: existing.publicIdentity, authority: "context-only"
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
const record = { signerId, keyId: publicIdentity.keyId, publicIdentity, createdAt, authority: "context-only" };
|
|
295
|
+
registry.signers = [...registry.signers.filter((item) => item.signerId !== signerId), record]
|
|
296
|
+
.sort((a, b) => a.signerId.localeCompare(b.signerId));
|
|
297
|
+
return { signerId, keyId: record.keyId, publicIdentity, retiredKeyId: existing?.keyId || null };
|
|
298
|
+
}
|
|
299
|
+
);
|
|
300
|
+
if (result.retiredKeyId) await unlink(privatePath(paths.signerDirectory, result.retiredKeyId)).catch((error) => {
|
|
301
|
+
if (error.code !== "ENOENT") throw error;
|
|
302
|
+
});
|
|
303
|
+
if (publicOut) {
|
|
304
|
+
await mkdir(dirname(publicTarget), { recursive: true, mode: 0o700 });
|
|
305
|
+
await writeFile(publicTarget, `${JSON.stringify(result.publicIdentity, null, 2)}\n`, { flag: "wx", mode: 0o644 });
|
|
306
|
+
}
|
|
307
|
+
return { ...result, publicOut: publicTarget, authority: "context-only" };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function listSigningIdentities({ root = process.cwd() } = {}) {
|
|
311
|
+
const paths = await authPaths(root);
|
|
312
|
+
const registry = await readBoundedJson(paths.registryPath, emptyRegistry, normalizeRegistry);
|
|
313
|
+
return {
|
|
314
|
+
schema: registry.schema,
|
|
315
|
+
signers: registry.signers.map((record) => record.publicIdentity),
|
|
316
|
+
retired: registry.history.map((entry) => entry.value),
|
|
317
|
+
authority: "context-only"
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function inspectSignerRegistry(root = process.cwd(), providedCatalog = null) {
|
|
322
|
+
const paths = await authPaths(root, providedCatalog, { allowInside: true, create: false });
|
|
323
|
+
try {
|
|
324
|
+
const registry = await readBoundedJson(paths.registryPath, emptyRegistry, normalizeRegistry);
|
|
325
|
+
const errors = [];
|
|
326
|
+
for (const record of registry.signers) {
|
|
327
|
+
const path = privatePath(paths.signerDirectory, record.keyId);
|
|
328
|
+
try {
|
|
329
|
+
const metadata = await lstat(path);
|
|
330
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_PUBLIC_IDENTITY_BYTES) {
|
|
331
|
+
errors.push(`invalid-private-key-file:${record.signerId}`);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (process.platform !== "win32" && (metadata.mode & 0o077) !== 0) errors.push(`unsafe-private-key-mode:${record.signerId}`);
|
|
335
|
+
const privateKey = createPrivateKey(await readFile(path, "utf8"));
|
|
336
|
+
const publicKey = createPublicKey(privateKey).export({ format: "der", type: "spki" }).toString("base64");
|
|
337
|
+
if (privateKey.asymmetricKeyType !== "ed25519" || publicKey !== record.publicIdentity.publicKey) {
|
|
338
|
+
errors.push(`private-public-key-mismatch:${record.signerId}`);
|
|
339
|
+
}
|
|
340
|
+
} catch (error) {
|
|
341
|
+
errors.push(`unreadable-private-key:${record.signerId}:${error.code || "invalid"}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return { registry, registryPath: paths.registryPath, signerDirectory: paths.signerDirectory, errors, catalog: paths.catalog };
|
|
345
|
+
} catch (error) {
|
|
346
|
+
return {
|
|
347
|
+
registry: emptyRegistry(), registryPath: paths.registryPath, signerDirectory: paths.signerDirectory,
|
|
348
|
+
errors: [`unreadable-registry:${error.message}`], catalog: paths.catalog
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function activeIdentity(root, signerId) {
|
|
354
|
+
const paths = await authPaths(root);
|
|
355
|
+
const registry = await readBoundedJson(paths.registryPath, emptyRegistry, normalizeRegistry);
|
|
356
|
+
const record = registry.signers.find((item) => item.signerId === signerId);
|
|
357
|
+
if (!record) throw new Error(`unknown local signer: ${signerId}`);
|
|
358
|
+
const path = privatePath(paths.signerDirectory, record.keyId);
|
|
359
|
+
const metadata = await lstat(path);
|
|
360
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("signing private key must be a regular file");
|
|
361
|
+
if (process.platform !== "win32" && (metadata.mode & 0o077) !== 0) throw new Error("signing private key permissions are too broad");
|
|
362
|
+
const privateKey = createPrivateKey(await readFile(path, "utf8"));
|
|
363
|
+
if (privateKey.asymmetricKeyType !== "ed25519") throw new Error("signing private key is not Ed25519");
|
|
364
|
+
return { publicIdentity: record.publicIdentity, privateKey };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export async function signEnvelope({ root = process.cwd(), signerId, kind, payload, now = new Date() }) {
|
|
368
|
+
if (!new Set(["manifest", "event"]).has(kind)) throw new Error("signed envelope kind must be manifest or event");
|
|
369
|
+
const identity = await activeIdentity(root, signerId);
|
|
370
|
+
const body = {
|
|
371
|
+
schema: "agentspine.signed-envelope/v1",
|
|
372
|
+
kind,
|
|
373
|
+
signer: identity.publicIdentity,
|
|
374
|
+
payload,
|
|
375
|
+
signedAt: isoDate(now, "now"),
|
|
376
|
+
authority: "context-only"
|
|
377
|
+
};
|
|
378
|
+
const signature = cryptoSign(null, Buffer.from(canonical(body)), identity.privateKey).toString("base64");
|
|
379
|
+
return { ...body, signature };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function verifyEnvelope(envelope, expectedKind = null) {
|
|
383
|
+
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)
|
|
384
|
+
|| !exactKeys(envelope, ["schema", "kind", "signer", "payload", "signedAt", "authority", "signature"])
|
|
385
|
+
|| envelope.schema !== "agentspine.signed-envelope/v1"
|
|
386
|
+
|| !new Set(["manifest", "event"]).has(envelope.kind)
|
|
387
|
+
|| (expectedKind !== null && envelope.kind !== expectedKind)
|
|
388
|
+
|| !validDate(envelope.signedAt) || envelope.authority !== "context-only"
|
|
389
|
+
|| !SIGNATURE_RE.test(envelope.signature || "")) {
|
|
390
|
+
throw new Error("signed envelope structure is invalid");
|
|
391
|
+
}
|
|
392
|
+
validatePublicIdentity(envelope.signer);
|
|
393
|
+
const body = { ...envelope };
|
|
394
|
+
delete body.signature;
|
|
395
|
+
const key = createPublicKey({ key: Buffer.from(envelope.signer.publicKey, "base64"), format: "der", type: "spki" });
|
|
396
|
+
if (!cryptoVerify(null, Buffer.from(canonical(body)), key, Buffer.from(envelope.signature, "base64"))) {
|
|
397
|
+
throw new Error("signed envelope signature is invalid");
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
payload: envelope.payload,
|
|
401
|
+
authentication: {
|
|
402
|
+
mode: "signed",
|
|
403
|
+
signerId: envelope.signer.signerId,
|
|
404
|
+
keyId: envelope.signer.keyId,
|
|
405
|
+
signedAt: envelope.signedAt,
|
|
406
|
+
verifiedAt: null,
|
|
407
|
+
signature: envelope.signature,
|
|
408
|
+
authority: "context-only"
|
|
409
|
+
},
|
|
410
|
+
publicIdentity: envelope.signer
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function loadTrust(root = process.cwd(), providedCatalog = null) {
|
|
415
|
+
const paths = await authPaths(root, providedCatalog);
|
|
416
|
+
const trust = await readBoundedJson(
|
|
417
|
+
paths.trustPath,
|
|
418
|
+
() => emptyTrust(paths.catalog.root),
|
|
419
|
+
(value) => normalizeTrust(value, paths.catalog.root)
|
|
420
|
+
);
|
|
421
|
+
return { trust, trustPath: paths.trustPath, catalog: paths.catalog };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export async function inspectTrust(root = process.cwd(), providedCatalog = null) {
|
|
425
|
+
const paths = await authPaths(root, providedCatalog, { allowInside: true, create: false });
|
|
426
|
+
try {
|
|
427
|
+
const trust = await readBoundedJson(
|
|
428
|
+
paths.trustPath, () => emptyTrust(paths.catalog.root),
|
|
429
|
+
(value) => normalizeTrust(value, paths.catalog.root)
|
|
430
|
+
);
|
|
431
|
+
return { trust, trustPath: paths.trustPath, catalog: paths.catalog, error: null };
|
|
432
|
+
} catch (error) {
|
|
433
|
+
return { trust: emptyTrust(paths.catalog.root), trustPath: paths.trustPath, catalog: paths.catalog, error: error.message };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function trustMutation(root, operation) {
|
|
438
|
+
const paths = await authPaths(root);
|
|
439
|
+
return mutate(
|
|
440
|
+
paths.trustPath,
|
|
441
|
+
() => readBoundedJson(paths.trustPath, () => emptyTrust(paths.catalog.root), (value) => normalizeTrust(value, paths.catalog.root)),
|
|
442
|
+
trustFindings,
|
|
443
|
+
(trust) => operation(trust, paths)
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export async function trustSigner({ root = process.cwd(), publicIdentityPath, confirmation, now = new Date() }) {
|
|
448
|
+
requireConfirmation(confirmation);
|
|
449
|
+
if (!publicIdentityPath || typeof publicIdentityPath !== "string") throw new Error("public identity path is required");
|
|
450
|
+
const target = resolve(publicIdentityPath);
|
|
451
|
+
const metadata = await lstat(target);
|
|
452
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_PUBLIC_IDENTITY_BYTES) {
|
|
453
|
+
throw new Error("public identity must be a regular JSON file smaller than 16 KiB");
|
|
454
|
+
}
|
|
455
|
+
const publicIdentity = validatePublicIdentity(JSON.parse(await readFile(target, "utf8")));
|
|
456
|
+
const addedAt = isoDate(now, "now");
|
|
457
|
+
return trustMutation(root, (trust, paths) => {
|
|
458
|
+
const existing = trust.records.find((record) => record.keyId === publicIdentity.keyId);
|
|
459
|
+
if (existing?.status === "trusted") return { created: false, record: existing, trustPath: paths.trustPath };
|
|
460
|
+
if (existing?.status === "revoked") throw new Error("a revoked key cannot be silently trusted again; import a rotated key");
|
|
461
|
+
const record = {
|
|
462
|
+
signerId: publicIdentity.signerId, keyId: publicIdentity.keyId, publicIdentity,
|
|
463
|
+
status: "trusted", addedAt, revokedAt: null, reason: null,
|
|
464
|
+
confirmation: "local-owner", authority: "context-only"
|
|
465
|
+
};
|
|
466
|
+
trust.records.push(record);
|
|
467
|
+
trust.records.sort((a, b) => a.signerId.localeCompare(b.signerId) || a.keyId.localeCompare(b.keyId));
|
|
468
|
+
return { created: true, record, trustPath: paths.trustPath };
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export async function revokeTrustedSigner({ root = process.cwd(), keyId, reason, confirmation, now = new Date() }) {
|
|
473
|
+
requireConfirmation(confirmation);
|
|
474
|
+
if (!ID_RE.test(keyId || "")) throw new Error("keyId is required");
|
|
475
|
+
if (!reason || typeof reason !== "string" || !reason.trim()) throw new Error("revocation reason is required");
|
|
476
|
+
if (SECRET_RE.test(reason)) throw new Error("revocation reason appears to contain a secret");
|
|
477
|
+
const revokedAt = isoDate(now, "now");
|
|
478
|
+
return trustMutation(root, (trust, paths) => {
|
|
479
|
+
const record = trust.records.find((item) => item.keyId === keyId);
|
|
480
|
+
if (!record) throw new Error(`unknown trusted key: ${keyId}`);
|
|
481
|
+
if (record.status === "revoked") return { changed: false, record, trustPath: paths.trustPath };
|
|
482
|
+
trust.history.push({ kind: "trust-record", keyId, changedAt: revokedAt, value: { ...record }, authority: "context-only" });
|
|
483
|
+
const revoked = { ...record, status: "revoked", revokedAt, reason: reason.trim().slice(0, 500), authority: "context-only" };
|
|
484
|
+
trust.records = trust.records.map((item) => item.keyId === keyId ? revoked : item);
|
|
485
|
+
return { changed: true, record: revoked, trustPath: paths.trustPath };
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function assertTrustedIdentity(publicIdentity, trust) {
|
|
490
|
+
validatePublicIdentity(publicIdentity);
|
|
491
|
+
const findings = trustFindings(trust);
|
|
492
|
+
if (findings.length) throw new Error(`sharing trust state failed closed: ${findings.join(", ")}`);
|
|
493
|
+
const record = trust.records.find((item) => item.keyId === publicIdentity.keyId);
|
|
494
|
+
if (!record || record.status !== "trusted" || record.signerId !== publicIdentity.signerId
|
|
495
|
+
|| record.publicIdentity.digest !== publicIdentity.digest) {
|
|
496
|
+
throw new Error(`untrusted or revoked signing key: ${publicIdentity.keyId}`);
|
|
497
|
+
}
|
|
498
|
+
return record;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export async function trustedSignerContext({ root = process.cwd(), includeRevoked = false } = {}) {
|
|
502
|
+
const { trust, trustPath } = await loadTrust(root);
|
|
503
|
+
return {
|
|
504
|
+
schema: "agentspine.trusted-signers/v1",
|
|
505
|
+
root: trust.root,
|
|
506
|
+
signers: trust.records.filter((record) => includeRevoked || record.status === "trusted").map((record) => ({
|
|
507
|
+
signerId: record.signerId, keyId: record.keyId, status: record.status,
|
|
508
|
+
addedAt: record.addedAt, revokedAt: record.revokedAt, reason: record.reason,
|
|
509
|
+
authority: "context-only"
|
|
510
|
+
})),
|
|
511
|
+
trustPath,
|
|
512
|
+
authority: "context-only",
|
|
513
|
+
note: "A trusted signature authenticates origin only. It never grants permissions, delegation, or approval of the signed content."
|
|
514
|
+
};
|
|
515
|
+
}
|