imbas-verify 0.0.0-stage → 0.1.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/retirement.js ADDED
@@ -0,0 +1,521 @@
1
+ /**
2
+ * Key retirements: what a verifier is told when a key stops being trusted.
3
+ *
4
+ * The failure this exists for is not exotic. An installation key is on a laptop that is
5
+ * later stolen, or on a machine that is decommissioned and its disk handed to somebody
6
+ * else. Every record that key ever signed still verifies, forever, and a verifier has no
7
+ * way to learn that anything changed. The chain makes an edit visible, which is the honest
8
+ * boundary — but "verifiable now and verifiable in a hundred years, identically" is not a
9
+ * boundary, it is a missing feature.
10
+ *
11
+ * ## What a retirement is, and what it is not
12
+ *
13
+ * It is a statement of **distrust from a moment**, written by the operator and handed to
14
+ * a verifier. It is not a claim that past records were bad. The distinction is the whole
15
+ * design:
16
+ *
17
+ * - A record signed *before* the retirement was signed by a key that was trusted at the
18
+ * time. It is exactly as good as it ever was, and saying otherwise would retroactively
19
+ * convict every honest approval the machine ever made.
20
+ * - A record signed *after* the retirement claims to be signed by a key its own operator
21
+ * had already disowned. Either the retirement is wrong or the record is; either way a
22
+ * reader must be told.
23
+ *
24
+ * ## Why the list is a file, and why that is stated loudly
25
+ *
26
+ * There is no transparency log here. The list is a file the operator maintains and
27
+ * chooses to hand out, which means an operator who withholds it proves nothing, and one
28
+ * who holds it can leave an entry out. That is a real limit and `docs/EVIDENCE.md` says
29
+ * so in the same breath as the feature. The alternative — publishing retirements to a
30
+ * public log — is the thing this product deliberately does not do with an operator's
31
+ * signing history.
32
+ *
33
+ * The list is **append-only and never rewritten**. A "remove this retirement" operation
34
+ * would make the file worthless: a verifier reading the current state could not tell a
35
+ * key that was never retired from one whose retirement was quietly withdrawn.
36
+ */
37
+ import { readFileSync } from "node:fs";
38
+ import { verifyEnvelopeSignatures } from "./dsse.js";
39
+ import { tryKeyIdOfPem } from "./key.js";
40
+ import { join, resolve } from "node:path";
41
+ /**
42
+ * The version this code writes.
43
+ *
44
+ * 2.0.0 added `supersedes`. It is a major bump rather than a minor one because the entries
45
+ * are **signed**: a list written at 1.0.0 and a list written at 2.0.0 are different
46
+ * documents, and a verifier re-serialising one to check the other would produce bytes that
47
+ * never existed. Calling that a minor change would invite exactly that mistake.
48
+ */
49
+ export const RETIREMENT_SCHEMA_VERSION = "2.0.0";
50
+ /**
51
+ * Every version this code can read, and what each one permits.
52
+ *
53
+ * `1.0.0` is still read, because lists have already been handed out. It is not *upgraded*
54
+ * on read — it stays 1.0.0 and is reported as such — and it is never refused for lacking
55
+ * `supersedes`, which it could not have had.
56
+ */
57
+ const READABLE_VERSIONS = ["1.0.0", "2.0.0"];
58
+ /**
59
+ * The version assumed when a list carries none.
60
+ *
61
+ * The **oldest** readable one, not the newest. A document with no version predates the
62
+ * field, so reading it as 2.0.0 would be the silent reinterpretation this whole mechanism
63
+ * exists to prevent — and it would silently permit `supersedes` in a document that never
64
+ * declared it could carry it.
65
+ */
66
+ const ASSUMED_VERSION = "1.0.0";
67
+ /** The payload type for a retirement list. Distinct from an attestation's, so a list can
68
+ * never be presented as a record or the other way round. */
69
+ export const RETIREMENT_PAYLOAD_TYPE = "application/vnd.imbas.retirements+json";
70
+ export class RetirementError extends Error {
71
+ code;
72
+ constructor(code, message) {
73
+ super(message);
74
+ this.code = code;
75
+ this.name = "RetirementError";
76
+ }
77
+ }
78
+ /** Where the retirement list lives inside a data dir. */
79
+ export function retirementsPath(dataDir) {
80
+ return join(resolve(dataDir), "keys", "retirements.json");
81
+ }
82
+ export function readRetirementList(path) {
83
+ let text;
84
+ try {
85
+ text = readFileSync(resolve(path), "utf8");
86
+ }
87
+ catch {
88
+ // Absent, or unreadable. Both mean "you have not been given a list", which the caller
89
+ // must be able to distinguish from "you were given a list and the key is not in it".
90
+ return { ok: true, list: { schema_version: RETIREMENT_SCHEMA_VERSION, retirements: [] }, found: false, envelope: null };
91
+ }
92
+ let parsed;
93
+ try {
94
+ parsed = JSON.parse(text);
95
+ }
96
+ catch {
97
+ return {
98
+ ok: false,
99
+ code: "retirements_json",
100
+ message: `${path} is not valid JSON, so nothing can be concluded about which keys were retired.`,
101
+ };
102
+ }
103
+ // Both file shapes are read: the `{ entries, envelope }` form this slice writes, and the
104
+ // bare `{ retirements }` form written before signing existed. A verifier handed an old
105
+ // list must not be told their file is malformed — they must be told it is unsigned.
106
+ const signedShape = parseSignedList(parsed);
107
+ if (!signedShape.ok) {
108
+ return { ok: false, code: "retirements_unreadable", message: signedShape.message };
109
+ }
110
+ const { entries, envelope } = signedShape.list;
111
+ return {
112
+ ok: true,
113
+ list: { schema_version: entries.schema_version, retirements: entries.retirements },
114
+ found: true,
115
+ envelope,
116
+ };
117
+ }
118
+ /**
119
+ * Validates a parsed retirement list.
120
+ *
121
+ * Strict about shape and lenient about nothing, because the failure mode of a
122
+ * half-understood retirement list is a verifier that silently checks fewer entries than
123
+ * its author wrote. An entry missing `retired_at` would otherwise become `undefined`, and
124
+ * `new Date(undefined)` is an Invalid Date that compares false against everything — so a
125
+ * malformed entry would read as "retired after all records", or as "retired never",
126
+ * depending on which way the comparison fell. Refusing the file is the only safe answer.
127
+ */
128
+ export function parseRetirementList(value) {
129
+ if (typeof value !== "object" || value === null) {
130
+ return { ok: false, message: "A retirement list must be a JSON object with a `retirements` array." };
131
+ }
132
+ const candidate = value;
133
+ if (!Array.isArray(candidate.retirements)) {
134
+ return { ok: false, message: "A retirement list must have a `retirements` array." };
135
+ }
136
+ /*
137
+ * The version decides what the entries are allowed to contain.
138
+ *
139
+ * An unknown version is refused by name rather than guessed at. A 3.0.0 list might permit
140
+ * a field this code would silently drop — and dropping a signed field is worse than
141
+ * refusing the file, because the signature would still verify over a document this
142
+ * verifier has half-understood.
143
+ */
144
+ const declared = candidate.schema_version;
145
+ if (declared !== undefined && !READABLE_VERSIONS.includes(declared)) {
146
+ return {
147
+ ok: false,
148
+ message: `This registry declares schema_version "${String(declared)}", and this verifier reads ${READABLE_VERSIONS.join(" and ")}. Refusing rather than guessing at what an unknown version permits.`,
149
+ };
150
+ }
151
+ const version = (declared ?? ASSUMED_VERSION);
152
+ const retirements = [];
153
+ for (const [index, entry] of candidate.retirements.entries()) {
154
+ const shaped = parseRetirement(entry, version);
155
+ if (!shaped.ok) {
156
+ return { ok: false, message: `Retirement ${index} is not usable: ${shaped.message}` };
157
+ }
158
+ retirements.push(shaped.retirement);
159
+ }
160
+ return {
161
+ ok: true,
162
+ // The declared version, so a 1.0.0 list stays 1.0.0 when it is read back out. Returning
163
+ // the current version instead would relabel every old list as one that permits a field
164
+ // it never had.
165
+ list: { schema_version: version, retirements },
166
+ };
167
+ }
168
+ function parseRetirement(value, version = ASSUMED_VERSION) {
169
+ if (typeof value !== "object" || value === null) {
170
+ return { ok: false, message: "it is not an object." };
171
+ }
172
+ const candidate = value;
173
+ if (typeof candidate.keyid !== "string" || candidate.keyid.trim().length === 0) {
174
+ return { ok: false, message: "it has no `keyid`." };
175
+ }
176
+ if (typeof candidate.retired_at !== "string" || !isIsoDate(candidate.retired_at)) {
177
+ return {
178
+ ok: false,
179
+ message: `its \`retired_at\` is ${candidate.retired_at === undefined ? "missing" : "not an ISO 8601 timestamp"}. A retirement with no moment is a retirement that cannot be compared against anything.`,
180
+ };
181
+ }
182
+ if (typeof candidate.reason !== "string" || candidate.reason.trim().length === 0) {
183
+ return { ok: false, message: "it has no `reason`. A retirement nobody explained cannot be reviewed." };
184
+ }
185
+ /*
186
+ * `supersedes` is optional, and the two ways it can be wrong are different mistakes.
187
+ *
188
+ * Absent is legitimate — a stolen laptop has no successor — so a 1.0.0 entry that never
189
+ * had the field, and a 2.0.0 entry that deliberately omits it, both parse. Present but
190
+ * empty is refused: `supersedes: ""` would name no key at all while looking like it named
191
+ * one, and a reader would follow it to nothing.
192
+ */
193
+ let supersedes;
194
+ if (candidate.supersedes !== undefined) {
195
+ /*
196
+ * A 1.0.0 list cannot carry this field.
197
+ *
198
+ * Not a technicality: if it appears, either the version is mislabelled or somebody added
199
+ * a signed field without declaring it. Both mean the document is not what it says it is,
200
+ * and continuing would apply a successor the declared version never permitted.
201
+ */
202
+ if (version === "1.0.0") {
203
+ return {
204
+ ok: false,
205
+ message: "it carries `supersedes`, but the list declares schema_version 1.0.0, which has no such field. Either the version is wrong or a field was added without declaring it — a verifier cannot tell which, so it will not guess.",
206
+ };
207
+ }
208
+ if (typeof candidate.supersedes !== "string" || candidate.supersedes.trim().length === 0) {
209
+ return {
210
+ ok: false,
211
+ message: "its `supersedes` is present but names no key. Leave it out when the key has no successor; an empty one would look like a successor a reader could follow.",
212
+ };
213
+ }
214
+ /*
215
+ * Deliberately *not* a 64-hex shape check, matching how `keyid` itself is validated.
216
+ * Refusing a `supersedes` that `keyid` would accept would make the list inconsistent
217
+ * with itself — a strict shape here and a non-empty check there. What matters is that it
218
+ * names a *different* key, which is the check below.
219
+ */
220
+ if (candidate.supersedes === candidate.keyid) {
221
+ return {
222
+ ok: false,
223
+ message: "its `supersedes` names the key being retired. A key cannot succeed itself, and following that note would loop forever.",
224
+ };
225
+ }
226
+ supersedes = candidate.supersedes;
227
+ }
228
+ return {
229
+ ok: true,
230
+ retirement: {
231
+ keyid: candidate.keyid,
232
+ retired_at: candidate.retired_at,
233
+ reason: candidate.reason,
234
+ recorded_at: typeof candidate.recorded_at === "string" && isIsoDate(candidate.recorded_at)
235
+ ? candidate.recorded_at
236
+ : candidate.retired_at,
237
+ // Spread rather than `supersedes: undefined`: the signed bytes must not gain a key
238
+ // that was never there, or a 1.0.0 list and a re-written one would differ.
239
+ ...(supersedes === undefined ? {} : { supersedes }),
240
+ },
241
+ };
242
+ }
243
+ /**
244
+ * True when the text is an ISO 8601 timestamp that names a real instant.
245
+ *
246
+ * `new Date("nonsense")` produces an Invalid Date rather than throwing, and every
247
+ * comparison against an Invalid Date is `false` — including `>=` and `<=`. A timestamp
248
+ * that cannot be compared is worse than a missing one, because it silently answers "no"
249
+ * to every question asked of it.
250
+ */
251
+ export function isIsoDate(text) {
252
+ const parsed = Date.parse(text);
253
+ return Number.isFinite(parsed);
254
+ }
255
+ /**
256
+ * Appends a retirement.
257
+ *
258
+ * Refuses a duplicate keyid. Retiring the same key twice would produce a list where an
259
+ * earlier moment and a later one both claim to be the retirement, and a comparison that
260
+ * read the wrong one would call a post-retirement record genuine. The refusal is by name
261
+ * so an operator who runs the command twice learns what happened.
262
+ *
263
+ * Refuses to move a retirement *earlier* as well, for the same reason in the other
264
+ * direction: shortening the trusted period after records have been signed would
265
+ * retroactively disown approvals the operator made in good faith. A later retirement is
266
+ * a widening of trust, and this refuses that too — a list whose entries can be revised is
267
+ * a list a verifier cannot reason about.
268
+ */
269
+ /**
270
+ * Serialises the entries once and signs those bytes.
271
+ *
272
+ * `JSON.stringify` without indentation, not the two-space form the file is written with.
273
+ * The signature must cover a canonical byte string so that a verifier can reproduce it from
274
+ * `entries` alone without knowing how the writer chose to format the file. Key order is
275
+ * insertion order, which is deterministic for a given object, and the entries are built in
276
+ * one place — so a re-serialisation by `verifyListSignature` produces the same bytes.
277
+ */
278
+ /**
279
+ * Checks the signature over a list, before any entry is read.
280
+ *
281
+ * Returns one of five states rather than a boolean, because "there is no signature", "the
282
+ * signature is wrong" and "I was given no key to check it with" are three different facts
283
+ * and only one of them says somebody tampered.
284
+ *
285
+ * `policyPem` is optional. When it is absent a signed list reports
286
+ * `unverified_signature` — a list that *has* a signature nobody checked, which is again
287
+ * different from a list with none. Collapsing those would let an operator hand a verifier
288
+ * an unsigned list and have it read as "unsigned is normal here".
289
+ */
290
+ export function verifyListSignature(list, policyPem) {
291
+ const shaped = parseSignedList(list);
292
+ if (!shaped.ok) {
293
+ return { ok: false, state: { state: "signature_invalid", message: shaped.message }, message: shaped.message };
294
+ }
295
+ const { entries, envelope } = shaped.list;
296
+ if (envelope === null) {
297
+ // A bare list. Nothing has been altered — there is simply nothing to check. Reported,
298
+ // never refused, because a list written before this feature existed is not a forgery.
299
+ return { ok: true, state: { state: "unsigned" }, list: entries };
300
+ }
301
+ const keys = [];
302
+ if (policyPem !== null) {
303
+ const keyid = tryKeyIdOfPem(policyPem);
304
+ if (keyid === null) {
305
+ return {
306
+ ok: false,
307
+ state: { state: "signature_invalid", message: "The policy key supplied is not a PEM public key." },
308
+ message: "The policy key supplied is not a PEM public key, so the list's signature cannot be checked.",
309
+ };
310
+ }
311
+ keys.push({ publicPem: policyPem, keyid });
312
+ }
313
+ if (keys.length === 0) {
314
+ return {
315
+ ok: false,
316
+ state: { state: "unverified_signature" },
317
+ message: "This retirement list carries a signature, but no policy public key was supplied to check it. Pass --retirements-key, or the signature is decoration.",
318
+ };
319
+ }
320
+ const outcome = verifyEnvelopeSignatures(envelope, keys, { expectedPayloadType: RETIREMENT_PAYLOAD_TYPE });
321
+ if (!outcome.ok) {
322
+ if (outcome.check === "keyid") {
323
+ const signer = envelope.signatures[0]?.keyid ?? "(none)";
324
+ return {
325
+ ok: false,
326
+ state: { state: "unknown_signer", keyid: signer },
327
+ message: `This retirement list was signed by ${signer.slice(0, 16)}…, and the policy key you supplied is not it. Either you were given the wrong policy key, or the list was re-signed by somebody else.`,
328
+ };
329
+ }
330
+ return {
331
+ ok: false,
332
+ state: { state: "signature_invalid", message: outcome.message },
333
+ message: `The retirement list's signature does not verify: ${outcome.message} These are not the entries the policy key signed.`,
334
+ };
335
+ }
336
+ /*
337
+ * The signed bytes must equal the entries the file carries.
338
+ *
339
+ * Without this, an attacker could keep a valid envelope and swap `entries` for their own:
340
+ * the signature would still verify, because it verifies over the *payload inside the
341
+ * envelope*, and nothing would have compared that payload to what the reader will use.
342
+ * This is the one check that ties the signature to the thing being read.
343
+ */
344
+ const signedEntries = parseEntriesFromPayload(outcome.payload);
345
+ if (!signedEntries.ok) {
346
+ return { ok: false, state: { state: "signature_invalid", message: signedEntries.message }, message: signedEntries.message };
347
+ }
348
+ if (JSON.stringify(signedEntries.entries) !== JSON.stringify(entries)) {
349
+ const message = "The retirement list's signature covers different entries from the ones in the file. Somebody has changed the list since it was signed.";
350
+ return { ok: false, state: { state: "signature_invalid", message }, message };
351
+ }
352
+ return { ok: true, state: { state: "signed", keyid: outcome.signatures[0]?.keyid ?? "" }, list: entries };
353
+ }
354
+ /** Reads the entries out of a verified payload. */
355
+ function parseEntriesFromPayload(payload) {
356
+ if (payload === null)
357
+ return { ok: false, message: "The retirement list's payload could not be decoded." };
358
+ let parsed;
359
+ try {
360
+ parsed = JSON.parse(payload.toString("utf8"));
361
+ }
362
+ catch {
363
+ return { ok: false, message: "The retirement list's signed payload is not JSON." };
364
+ }
365
+ return parseEntries(parsed);
366
+ }
367
+ /** Validates an entries object. Shared by the signed and unsigned readers. */
368
+ function parseEntries(value) {
369
+ const list = parseRetirementList(value);
370
+ if (!list.ok)
371
+ return { ok: false, message: list.message };
372
+ return { ok: true, entries: { schema_version: list.list.schema_version, retirements: list.list.retirements } };
373
+ }
374
+ /** Accepts either a bare list or a `{ entries, envelope }` file. */
375
+ function parseSignedList(value) {
376
+ if (typeof value !== "object" || value === null) {
377
+ return { ok: false, message: "A retirement list must be a JSON object." };
378
+ }
379
+ const candidate = value;
380
+ if ("entries" in candidate) {
381
+ const entries = parseEntries(candidate.entries);
382
+ if (!entries.ok)
383
+ return { ok: false, message: entries.message };
384
+ const envelope = candidate.envelope;
385
+ if (envelope === undefined || envelope === null) {
386
+ return { ok: true, list: { entries: entries.entries, envelope: null } };
387
+ }
388
+ if (typeof envelope !== "object" ||
389
+ typeof envelope.payloadType !== "string" ||
390
+ typeof envelope.payload !== "string" ||
391
+ !Array.isArray(envelope.signatures)) {
392
+ return { ok: false, message: "The retirement list's `envelope` is not a usable DSSE envelope." };
393
+ }
394
+ if (envelope.payloadType !== RETIREMENT_PAYLOAD_TYPE) {
395
+ return {
396
+ ok: false,
397
+ message: `The retirement list's envelope declares payloadType "${envelope.payloadType}", not "${RETIREMENT_PAYLOAD_TYPE}". A record envelope cannot stand in for a list's.`,
398
+ };
399
+ }
400
+ return { ok: true, list: { entries: entries.entries, envelope } };
401
+ }
402
+ // A bare list. Treated as unsigned rather than as a shape error, so a file written
403
+ // before signing existed still reads — and is reported as `unsigned`.
404
+ const entries = parseEntries(value);
405
+ if (!entries.ok)
406
+ return { ok: false, message: entries.message };
407
+ return { ok: true, list: { entries: entries.entries, envelope: null } };
408
+ }
409
+ /**
410
+ * Follows the successors from a keyid, to the end of the chain.
411
+ *
412
+ * Read-only: it walks the signed list and invents nothing, signs nothing, and changes no
413
+ * trust. The walk terminates on the first key that is not retired, on a successor the list
414
+ * does not mention, or on a repeat — and reports *which* it was, because they mean
415
+ * different things to a reader deciding whether a signature is still worth anything.
416
+ *
417
+ * It checks no signature. Call `verifyListSignature` first: following an unverified list is
418
+ * following an unsigned claim, which this whole module exists to avoid.
419
+ */
420
+ export function followSuccessors(list, keyid) {
421
+ const path = [];
422
+ let current = keyid;
423
+ for (;;) {
424
+ const alreadyWalked = path.find((hop) => hop.keyid === current);
425
+ if (alreadyWalked !== undefined) {
426
+ return { state: "cycle", keyid, path, repeated: alreadyWalked.keyid };
427
+ }
428
+ const retirement = list.retirements.find((entry) => entry.keyid === current);
429
+ if (retirement === undefined) {
430
+ // An empty path means the key asked about was never listed — the one answer that is a
431
+ // fact about the key rather than about the chain.
432
+ if (path.length === 0)
433
+ return { state: "not_retired", keyid };
434
+ return { state: "successor_not_listed", keyid, path, successor: current };
435
+ }
436
+ path.push({ keyid: current, retirement });
437
+ if (retirement.supersedes === undefined) {
438
+ return { state: "retired_without_successor", keyid, path };
439
+ }
440
+ current = retirement.supersedes;
441
+ }
442
+ }
443
+ /**
444
+ * The walk in one sentence.
445
+ *
446
+ * Deliberately talks about *notes* rather than trust. A chain ending at "key C" says the
447
+ * operator wrote down that C replaced B; it does not say C is trusted, or even that it
448
+ * exists. Nothing here is revocation, expiry, or a statement about time.
449
+ */
450
+ export function describeSuccession(chain) {
451
+ const trail = (path) => path
452
+ .map((hop) => hop.keyid.slice(0, 8))
453
+ .concat(chain.state === "successor_not_listed" ? [chain.successor.slice(0, 8)] : [])
454
+ .join(" → ");
455
+ const last = (path) => path[path.length - 1].retirement;
456
+ switch (chain.state) {
457
+ case "not_retired":
458
+ return "not in this list. That is not a statement that it is trusted — only that nobody has recorded it as retired.";
459
+ case "retired_without_successor":
460
+ return chain.path.length === 1
461
+ ? `retired ${last(chain.path).retired_at} (${last(chain.path).reason}) and no successor was named. Nothing replaced this key.`
462
+ : `superseded along ${trail(chain.path)}, then retired ${last(chain.path).retired_at} (${last(chain.path).reason}) with no successor named. The chain ends here.`;
463
+ case "successor_not_listed":
464
+ return `superseded along ${trail(chain.path)}, pointing at a key that is not in this list — so whether it has itself been retired cannot be said. A successor is a note, not a proof: it does not say whether the successor key is trusted.`;
465
+ case "cycle":
466
+ return `the successor notes lead back to ${chain.repeated.slice(0, 8)} (${trail(chain.path)} → ${chain.repeated.slice(0, 8)}), so the chain has no end. This list was not written by the command in this repository.`;
467
+ }
468
+ }
469
+ /**
470
+ * One sentence per retirement state, in the words the docs use.
471
+ *
472
+ * Shared by the CLI and the API on purpose. Two copies would drift, and the drift would be
473
+ * invisible: an operator reading the terminal and a verifier reading the panel would be
474
+ * told subtly different things about the same record.
475
+ */
476
+ export function describeVerdict(verdict) {
477
+ if (verdict === undefined) {
478
+ return "no signer was checked, so nothing can be said about whether this key was retired.";
479
+ }
480
+ switch (verdict.state) {
481
+ case "unchecked":
482
+ return "unchecked — no retirement list was supplied. That is not the same as checked and clear.";
483
+ case "not_listed":
484
+ return "key not in the list you supplied.";
485
+ case "retired_after_signing":
486
+ return `retired ${verdict.retirement.retired_at} (${verdict.retirement.reason}) — after this record was sealed, so it was signed while the key was trusted.`;
487
+ case "no_signing_time":
488
+ return `retired ${verdict.retirement.retired_at} (${verdict.retirement.reason}), but this record names no completed_at, so whether it predates the retirement cannot be decided.`;
489
+ }
490
+ }
491
+ /**
492
+ * Decides what the list says about a record signed by this keyid.
493
+ *
494
+ * **The signing moment is the whole record's, not one signature's.** An envelope does not
495
+ * carry a per-signature timestamp, and inventing one would be a claim the format does not
496
+ * make. So the question is asked of the record's *envelope* — the interval from the signed
497
+ * `created_at` to the signed `completed_at` — and the later end is what decides:
498
+ *
499
+ * - A record whose `completed_at` is at or after the retirement is `retired_before_signing`.
500
+ * The key had been disowned by the time the record was sealed, so the signature claims
501
+ * something its own operator had withdrawn.
502
+ * - A record whose `completed_at` is before the retirement is `retired_after_signing`: the
503
+ * key was trusted throughout the job it describes.
504
+ *
505
+ * Using `completed_at` rather than `created_at` is deliberate. A long job that *started*
506
+ * before the retirement and finished after it was signed after the key was disowned. The
507
+ * earlier timestamp would launder exactly the case this feature exists to catch.
508
+ */
509
+ export function judgeRetirement(list, keyid, signing) {
510
+ const retirement = list.retirements.find((entry) => entry.keyid === keyid);
511
+ if (retirement === undefined)
512
+ return { state: "not_listed" };
513
+ if (signing.completedAt === null || !isIsoDate(signing.completedAt)) {
514
+ return { state: "no_signing_time", retirement };
515
+ }
516
+ const completed = Date.parse(signing.completedAt);
517
+ const retired = Date.parse(retirement.retired_at);
518
+ return completed >= retired
519
+ ? { state: "retired_before_signing", retirement }
520
+ : { state: "retired_after_signing", retirement };
521
+ }
package/statement.d.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The in-toto Statement, and the envelope it travels in.
3
+ *
4
+ * An in-toto Statement answers two questions and refuses to mix them: *what* is
5
+ * being attested (the `subject`) and *what is being said about it* (the `predicate`,
6
+ * named by its `predicateType`). Keeping them apart is what lets a third party read
7
+ * the subject — a digest of a patch — and check it against a file they already have,
8
+ * without having to understand or trust the predicate at all.
9
+ *
10
+ * The predicate here is an approval record. It is not a claim that code is correct,
11
+ * and the predicate says so in its own words (step 3). A signed record that overstates
12
+ * what it proves is worse than no record, because it gets believed.
13
+ */
14
+ import { APPROVAL_PREDICATE_TYPE, DSSE_PAYLOAD_TYPE, IN_TOTO_STATEMENT_TYPE } from "./dsse.js";
15
+ /** A subject digest: the algorithm and the hex digest of the exact bytes. */
16
+ export interface SubjectDigest {
17
+ sha256: string;
18
+ [algorithm: string]: string;
19
+ }
20
+ export interface Subject {
21
+ /** What the record is about. For an approval: the artifact it approved. */
22
+ name: string;
23
+ digest: SubjectDigest;
24
+ /**
25
+ * The commit the patch was applied as, when it was applied.
26
+ *
27
+ * Absent otherwise, and never filled in by guesswork: a record that named a commit
28
+ * that does not exist would be a false statement in a document whose only value is
29
+ * that its statements are true.
30
+ */
31
+ gitCommit?: string;
32
+ }
33
+ export interface Statement<T> {
34
+ _type: typeof IN_TOTO_STATEMENT_TYPE;
35
+ subject: Subject[];
36
+ predicateType: typeof APPROVAL_PREDICATE_TYPE;
37
+ predicate: T;
38
+ }
39
+ /** Builds a statement around a predicate. The caller supplies the subject. */
40
+ export declare function buildStatement<T>(subject: Subject, predicate: T): Statement<T>;
41
+ /**
42
+ * Reads a statement back out of verified payload bytes.
43
+ *
44
+ * Returns a discriminated result rather than throwing, so the verify command can name
45
+ * the check that failed instead of printing a stack trace. The `_type` and
46
+ * `predicateType` checks are here and not in `verifyEnvelope` on purpose: the envelope
47
+ * is a generic container, and it is the *reader* that knows what it expects to find
48
+ * inside.
49
+ */
50
+ export type StatementRead<T> = {
51
+ ok: true;
52
+ statement: Statement<T>;
53
+ } | {
54
+ ok: false;
55
+ check: "statement_json" | "statement_type" | "predicate_type";
56
+ message: string;
57
+ };
58
+ export declare function readStatement<T>(payload: Buffer): StatementRead<T>;
59
+ /** Re-exported so a caller needs one import for the whole format. */
60
+ export { APPROVAL_PREDICATE_TYPE, DSSE_PAYLOAD_TYPE, IN_TOTO_STATEMENT_TYPE };
package/statement.js ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The in-toto Statement, and the envelope it travels in.
3
+ *
4
+ * An in-toto Statement answers two questions and refuses to mix them: *what* is
5
+ * being attested (the `subject`) and *what is being said about it* (the `predicate`,
6
+ * named by its `predicateType`). Keeping them apart is what lets a third party read
7
+ * the subject — a digest of a patch — and check it against a file they already have,
8
+ * without having to understand or trust the predicate at all.
9
+ *
10
+ * The predicate here is an approval record. It is not a claim that code is correct,
11
+ * and the predicate says so in its own words (step 3). A signed record that overstates
12
+ * what it proves is worse than no record, because it gets believed.
13
+ */
14
+ import { APPROVAL_PREDICATE_TYPE, DSSE_PAYLOAD_TYPE, IN_TOTO_STATEMENT_TYPE, } from "./dsse.js";
15
+ /** Builds a statement around a predicate. The caller supplies the subject. */
16
+ export function buildStatement(subject, predicate) {
17
+ return {
18
+ _type: IN_TOTO_STATEMENT_TYPE,
19
+ subject: [subject],
20
+ predicateType: APPROVAL_PREDICATE_TYPE,
21
+ predicate,
22
+ };
23
+ }
24
+ export function readStatement(payload) {
25
+ let parsed;
26
+ try {
27
+ parsed = JSON.parse(payload.toString("utf8"));
28
+ }
29
+ catch {
30
+ return { ok: false, check: "statement_json", message: "The payload is not JSON." };
31
+ }
32
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
33
+ return { ok: false, check: "statement_json", message: "The payload is not a JSON object." };
34
+ }
35
+ const statement = parsed;
36
+ if (statement._type !== IN_TOTO_STATEMENT_TYPE) {
37
+ return {
38
+ ok: false,
39
+ check: "statement_type",
40
+ message: `_type is "${String(statement._type)}", expected "${IN_TOTO_STATEMENT_TYPE}".`,
41
+ };
42
+ }
43
+ if (statement.predicateType !== APPROVAL_PREDICATE_TYPE) {
44
+ return {
45
+ ok: false,
46
+ check: "predicate_type",
47
+ message: `predicateType is "${String(statement.predicateType)}", expected "${APPROVAL_PREDICATE_TYPE}".`,
48
+ };
49
+ }
50
+ if (!Array.isArray(statement.subject) || statement.subject.length === 0) {
51
+ return { ok: false, check: "statement_json", message: "The statement names no subject." };
52
+ }
53
+ return { ok: true, statement: statement };
54
+ }
55
+ /** Re-exported so a caller needs one import for the whole format. */
56
+ export { APPROVAL_PREDICATE_TYPE, DSSE_PAYLOAD_TYPE, IN_TOTO_STATEMENT_TYPE };
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/verify-cli.js ADDED
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `imbas-verify` — check a signed Imbas approval record without the engine.
4
+ *
5
+ * This is the free verifier from `docs/COMMERCIALS.md` §3.3, packaged. The claim it makes
6
+ * is narrow and worth stating plainly: given a record and the public key of the
7
+ * installation that signed it, it tells you whether the signature holds and what the record
8
+ * says. It does not talk to a network, read a config file, or need a data directory.
9
+ *
10
+ * The command syntax mirrors `imbas verify`, because they are the same implementation and
11
+ * a person moving between the two should not have to relearn the flags.
12
+ *
13
+ * Exit codes are the interface: 0 verified, 1 failed, 2 usage. Prose is for people.
14
+ */
15
+ import { runVerify } from "./verify.js";
16
+ import { VERIFY_USAGE, parseVerifyArgs } from "./args.js";
17
+ const argv = process.argv.slice(2);
18
+ if (argv.length === 0 || argv.includes("-h") || argv.includes("--help")) {
19
+ process.stderr.write(`${VERIFY_USAGE}\n`);
20
+ process.exitCode = argv.length === 0 ? 2 : 0;
21
+ }
22
+ else {
23
+ const parsed = parseVerifyArgs(argv);
24
+ if (!parsed.ok) {
25
+ process.stderr.write(`${parsed.message}\n${VERIFY_USAGE}\n`);
26
+ process.exitCode = 2;
27
+ }
28
+ else {
29
+ process.exitCode = runVerify(parsed.args, {
30
+ out: (text) => process.stdout.write(text),
31
+ err: (text) => process.stderr.write(text),
32
+ });
33
+ }
34
+ }