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.
@@ -0,0 +1,337 @@
1
+ import { type DsseEnvelope } from "./dsse.js";
2
+ /** One retirement, as it appears in the list. */
3
+ export interface KeyRetirement {
4
+ keyid: string;
5
+ /** ISO 8601. Records signed at or after this moment are the alarming ones. */
6
+ retired_at: string;
7
+ /** Why. Free text: "laptop stolen", "machine decommissioned". */
8
+ reason: string;
9
+ /** When the entry was written. Equal to `retired_at` for a timely retirement. */
10
+ recorded_at: string;
11
+ /**
12
+ * The keyid that replaces this one, when there is one.
13
+ *
14
+ * A **note**, not a proof. It says the operator intends that key to be used from here on;
15
+ * it says nothing about whether that key is trustworthy, whether the holder is the same
16
+ * person, or whether it was ever used. The only thing that makes the note worth reading is
17
+ * the policy key's signature over the whole list.
18
+ *
19
+ * Optional on purpose. A laptop that was stolen has no successor, and making the field
20
+ * required would turn "the key is gone" into a malformed entry. `undefined` and
21
+ * "superseded by B" are different facts and both are legitimate.
22
+ */
23
+ supersedes?: string;
24
+ }
25
+ export interface RetirementList {
26
+ schema_version: string;
27
+ retirements: KeyRetirement[];
28
+ }
29
+ /**
30
+ * A retirement list and the signature over it.
31
+ *
32
+ * The signature covers `entries` — the object holding `retirements` — and **not** the
33
+ * envelope that carries it, exactly as an attestation's signature covers the statement
34
+ * rather than the file. What a verifier must be able to establish is "these are the entries
35
+ * the policy key signed", and nothing about how the file was laid out.
36
+ *
37
+ * The payload is serialised once by `sealList` and those bytes are what travel. A verifier
38
+ * re-serialising the entries to check the signature would be checking a document that
39
+ * happened to mean the same thing, which is the failure the counter-signature slice spent
40
+ * its whole length avoiding.
41
+ */
42
+ export interface SignedRetirementList {
43
+ /** The entries, exactly as signed. */
44
+ entries: {
45
+ retirements: KeyRetirement[];
46
+ schema_version: string;
47
+ };
48
+ /** The DSSE envelope over the canonical bytes of `entries`; null when unsigned. */
49
+ envelope: DsseEnvelope | null;
50
+ }
51
+ /** What a verifier concluded about the list's own integrity. */
52
+ export type ListSignatureState =
53
+ /** The list carries an envelope and it verifies against the policy key supplied. */
54
+ {
55
+ state: "signed";
56
+ keyid: string;
57
+ }
58
+ /** The list is a bare list of entries with no envelope at all. */
59
+ | {
60
+ state: "unsigned";
61
+ }
62
+ /** An envelope is present but does not verify — the entries are not the signed ones. */
63
+ | {
64
+ state: "signature_invalid";
65
+ message: string;
66
+ }
67
+ /** The envelope verifies against a keyid the verifier supplied no key for. */
68
+ | {
69
+ state: "unknown_signer";
70
+ keyid: string;
71
+ }
72
+ /** An envelope is present, but the verifier supplied no policy key to check it with. */
73
+ | {
74
+ state: "unverified_signature";
75
+ };
76
+ /**
77
+ * The version this code writes.
78
+ *
79
+ * 2.0.0 added `supersedes`. It is a major bump rather than a minor one because the entries
80
+ * are **signed**: a list written at 1.0.0 and a list written at 2.0.0 are different
81
+ * documents, and a verifier re-serialising one to check the other would produce bytes that
82
+ * never existed. Calling that a minor change would invite exactly that mistake.
83
+ */
84
+ export declare const RETIREMENT_SCHEMA_VERSION = "2.0.0";
85
+ /** The payload type for a retirement list. Distinct from an attestation's, so a list can
86
+ * never be presented as a record or the other way round. */
87
+ export declare const RETIREMENT_PAYLOAD_TYPE = "application/vnd.imbas.retirements+json";
88
+ export declare class RetirementError extends Error {
89
+ readonly code: string;
90
+ constructor(code: string, message: string);
91
+ }
92
+ /** Where the retirement list lives inside a data dir. */
93
+ export declare function retirementsPath(dataDir: string): string;
94
+ /**
95
+ * Reads a retirement list, or returns an empty one when the file does not exist.
96
+ *
97
+ * **A missing file is an empty list, and that is not the same as "nothing is retired".**
98
+ * The caller must be able to tell those apart, so this returns `found` alongside the list
99
+ * and `lookupRetirement` reports `unknown` rather than `live` when the keyid is absent.
100
+ * Collapsing the two would make every verifier who was never handed a list report a
101
+ * confident "key not retired", which is the exact overclaim this whole module exists to
102
+ * avoid.
103
+ */
104
+ export type RetirementRead = {
105
+ ok: true;
106
+ list: RetirementList;
107
+ found: boolean;
108
+ /**
109
+ * The signature state, when the file carried an envelope. `null` means the file was a
110
+ * bare list with nothing to check — which `verifyListSignature` reports as `unsigned`.
111
+ *
112
+ * Carried here rather than checked here: this function's job is to read a file, and
113
+ * checking a signature needs a policy key the reader may not have. It reports what it
114
+ * saw so the caller can decide.
115
+ */
116
+ envelope: DsseEnvelope | null;
117
+ } | {
118
+ ok: false;
119
+ code: "retirements_json" | "retirements_unreadable";
120
+ message: string;
121
+ };
122
+ export declare function readRetirementList(path: string): RetirementRead;
123
+ /**
124
+ * Validates a parsed retirement list.
125
+ *
126
+ * Strict about shape and lenient about nothing, because the failure mode of a
127
+ * half-understood retirement list is a verifier that silently checks fewer entries than
128
+ * its author wrote. An entry missing `retired_at` would otherwise become `undefined`, and
129
+ * `new Date(undefined)` is an Invalid Date that compares false against everything — so a
130
+ * malformed entry would read as "retired after all records", or as "retired never",
131
+ * depending on which way the comparison fell. Refusing the file is the only safe answer.
132
+ */
133
+ export declare function parseRetirementList(value: unknown): {
134
+ ok: true;
135
+ list: RetirementList;
136
+ } | {
137
+ ok: false;
138
+ message: string;
139
+ };
140
+ /**
141
+ * True when the text is an ISO 8601 timestamp that names a real instant.
142
+ *
143
+ * `new Date("nonsense")` produces an Invalid Date rather than throwing, and every
144
+ * comparison against an Invalid Date is `false` — including `>=` and `<=`. A timestamp
145
+ * that cannot be compared is worse than a missing one, because it silently answers "no"
146
+ * to every question asked of it.
147
+ */
148
+ export declare function isIsoDate(text: string): boolean;
149
+ /**
150
+ * Appends a retirement.
151
+ *
152
+ * Refuses a duplicate keyid. Retiring the same key twice would produce a list where an
153
+ * earlier moment and a later one both claim to be the retirement, and a comparison that
154
+ * read the wrong one would call a post-retirement record genuine. The refusal is by name
155
+ * so an operator who runs the command twice learns what happened.
156
+ *
157
+ * Refuses to move a retirement *earlier* as well, for the same reason in the other
158
+ * direction: shortening the trusted period after records have been signed would
159
+ * retroactively disown approvals the operator made in good faith. A later retirement is
160
+ * a widening of trust, and this refuses that too — a list whose entries can be revised is
161
+ * a list a verifier cannot reason about.
162
+ */
163
+ /**
164
+ * Serialises the entries once and signs those bytes.
165
+ *
166
+ * `JSON.stringify` without indentation, not the two-space form the file is written with.
167
+ * The signature must cover a canonical byte string so that a verifier can reproduce it from
168
+ * `entries` alone without knowing how the writer chose to format the file. Key order is
169
+ * insertion order, which is deterministic for a given object, and the entries are built in
170
+ * one place — so a re-serialisation by `verifyListSignature` produces the same bytes.
171
+ */
172
+ /**
173
+ * Checks the signature over a list, before any entry is read.
174
+ *
175
+ * Returns one of five states rather than a boolean, because "there is no signature", "the
176
+ * signature is wrong" and "I was given no key to check it with" are three different facts
177
+ * and only one of them says somebody tampered.
178
+ *
179
+ * `policyPem` is optional. When it is absent a signed list reports
180
+ * `unverified_signature` — a list that *has* a signature nobody checked, which is again
181
+ * different from a list with none. Collapsing those would let an operator hand a verifier
182
+ * an unsigned list and have it read as "unsigned is normal here".
183
+ */
184
+ export declare function verifyListSignature(list: unknown, policyPem: string | null): {
185
+ ok: true;
186
+ state: ListSignatureState;
187
+ list: RetirementList;
188
+ } | {
189
+ ok: false;
190
+ state: ListSignatureState;
191
+ message: string;
192
+ };
193
+ /** What a verifier can conclude about one keyid. */
194
+ export type RetirementVerdict =
195
+ /** The keyid is in the list, and the record was signed at or after the retirement. */
196
+ {
197
+ state: "retired_before_signing";
198
+ retirement: KeyRetirement;
199
+ }
200
+ /** The keyid is in the list, and the record was signed before the retirement. */
201
+ | {
202
+ state: "retired_after_signing";
203
+ retirement: KeyRetirement;
204
+ }
205
+ /** The keyid is not in the list the verifier holds. Says nothing about trust. */
206
+ | {
207
+ state: "not_listed";
208
+ }
209
+ /**
210
+ * The record names no signing moment this can be compared against, so the question
211
+ * cannot be answered. Distinct from both other answers: `not_listed` means "I looked
212
+ * and it is absent", this means "I could not look".
213
+ */
214
+ | {
215
+ state: "no_signing_time";
216
+ retirement: KeyRetirement;
217
+ };
218
+ /**
219
+ * What following the successors from one keyid produced.
220
+ *
221
+ * The two states that must never be confused are **"retired, and nothing replaced it"** and
222
+ * **"never retired at all"** — opposite facts about a key, and collapsing them would let a
223
+ * dead key read as live or a live one as dead.
224
+ *
225
+ * `path` is the walk itself, from the key asked about to the last retirement reached, so a
226
+ * reader can see A → B → C rather than being told a flat answer. Every path ends at a
227
+ * retirement; `state` says *why* the walk stopped there.
228
+ *
229
+ * Nothing here invents trust. A successor is a note the operator wrote; the only thing that
230
+ * makes it worth reading is the policy key's signature over the list it lives in.
231
+ */
232
+ export type SuccessionChain =
233
+ /** The key asked about is not in the list. This says nothing about whether it is trusted. */
234
+ {
235
+ state: "not_retired";
236
+ keyid: string;
237
+ }
238
+ /**
239
+ * The walk reached a retirement that names no successor — a stolen or decommissioned
240
+ * machine. A `path` of length one is the simple case: this key was retired and nothing
241
+ * replaced it.
242
+ */
243
+ | {
244
+ state: "retired_without_successor";
245
+ keyid: string;
246
+ path: SuccessionHop[];
247
+ }
248
+ /**
249
+ * The walk stopped because a retirement names a successor that is **not in this list**, so
250
+ * whether that key was itself retired cannot be said. Not an error: a successor is a note,
251
+ * not a proof, and a note may point outside the document that carries it.
252
+ */
253
+ | {
254
+ state: "successor_not_listed";
255
+ keyid: string;
256
+ path: SuccessionHop[];
257
+ successor: string;
258
+ }
259
+ /**
260
+ * The notes lead back to a key already walked. Only reachable from a hand-edited list,
261
+ * since both write paths refuse a self-successor. Reported rather than looped on, because
262
+ * a verifier that hangs on a malformed file is a denial of service of its own.
263
+ */
264
+ | {
265
+ state: "cycle";
266
+ keyid: string;
267
+ path: SuccessionHop[];
268
+ repeated: string;
269
+ };
270
+ /** One step of a succession walk: a keyid and the retirement record that ended its turn. */
271
+ export type SuccessionHop = {
272
+ keyid: string;
273
+ retirement: KeyRetirement;
274
+ };
275
+ /**
276
+ * Follows the successors from a keyid, to the end of the chain.
277
+ *
278
+ * Read-only: it walks the signed list and invents nothing, signs nothing, and changes no
279
+ * trust. The walk terminates on the first key that is not retired, on a successor the list
280
+ * does not mention, or on a repeat — and reports *which* it was, because they mean
281
+ * different things to a reader deciding whether a signature is still worth anything.
282
+ *
283
+ * It checks no signature. Call `verifyListSignature` first: following an unverified list is
284
+ * following an unsigned claim, which this whole module exists to avoid.
285
+ */
286
+ export declare function followSuccessors(list: RetirementList, keyid: string): SuccessionChain;
287
+ /**
288
+ * The walk in one sentence.
289
+ *
290
+ * Deliberately talks about *notes* rather than trust. A chain ending at "key C" says the
291
+ * operator wrote down that C replaced B; it does not say C is trusted, or even that it
292
+ * exists. Nothing here is revocation, expiry, or a statement about time.
293
+ */
294
+ export declare function describeSuccession(chain: SuccessionChain): string;
295
+ /**
296
+ * One sentence per retirement state, in the words the docs use.
297
+ *
298
+ * Shared by the CLI and the API on purpose. Two copies would drift, and the drift would be
299
+ * invisible: an operator reading the terminal and a verifier reading the panel would be
300
+ * told subtly different things about the same record.
301
+ */
302
+ export declare function describeVerdict(verdict: {
303
+ keyid: string;
304
+ state: "unchecked";
305
+ } | {
306
+ keyid: string;
307
+ state: "not_listed";
308
+ } | {
309
+ keyid: string;
310
+ state: "retired_after_signing";
311
+ retirement: KeyRetirement;
312
+ } | {
313
+ keyid: string;
314
+ state: "no_signing_time";
315
+ retirement: KeyRetirement;
316
+ } | undefined): string;
317
+ /**
318
+ * Decides what the list says about a record signed by this keyid.
319
+ *
320
+ * **The signing moment is the whole record's, not one signature's.** An envelope does not
321
+ * carry a per-signature timestamp, and inventing one would be a claim the format does not
322
+ * make. So the question is asked of the record's *envelope* — the interval from the signed
323
+ * `created_at` to the signed `completed_at` — and the later end is what decides:
324
+ *
325
+ * - A record whose `completed_at` is at or after the retirement is `retired_before_signing`.
326
+ * The key had been disowned by the time the record was sealed, so the signature claims
327
+ * something its own operator had withdrawn.
328
+ * - A record whose `completed_at` is before the retirement is `retired_after_signing`: the
329
+ * key was trusted throughout the job it describes.
330
+ *
331
+ * Using `completed_at` rather than `created_at` is deliberate. A long job that *started*
332
+ * before the retirement and finished after it was signed after the key was disowned. The
333
+ * earlier timestamp would launder exactly the case this feature exists to catch.
334
+ */
335
+ export declare function judgeRetirement(list: RetirementList, keyid: string, signing: {
336
+ completedAt: string | null;
337
+ }): RetirementVerdict;