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/CHANGELOG.md +35 -0
- package/FORMAT.md +155 -0
- package/LICENSE +17 -0
- package/NOTICE +21 -0
- package/README.md +77 -2
- package/args.d.ts +46 -0
- package/args.js +113 -0
- package/attestation.d.ts +178 -0
- package/attestation.js +199 -0
- package/describe.d.ts +8 -0
- package/describe.js +15 -0
- package/dsse.d.ts +130 -0
- package/dsse.js +211 -0
- package/key.d.ts +132 -0
- package/key.js +142 -0
- package/package.json +43 -4
- package/read-file.d.ts +33 -0
- package/read-file.js +25 -0
- package/record.d.ts +127 -0
- package/record.js +1 -0
- package/retirement.d.ts +337 -0
- package/retirement.js +521 -0
- package/statement.d.ts +60 -0
- package/statement.js +56 -0
- package/verify-cli.d.ts +2 -0
- package/verify-cli.js +34 -0
- package/verify.d.ts +6 -0
- package/verify.js +145 -0
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 };
|
package/verify-cli.d.ts
ADDED
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
|
+
}
|