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.d.ts
ADDED
|
@@ -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;
|