imbas-verify 0.0.0-stage → 0.1.0
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/FORMAT.md +155 -0
- package/LICENSE +17 -0
- package/NOTICE +21 -0
- package/README.md +78 -3
- package/args.d.ts +46 -0
- package/args.js +113 -0
- package/attestation.d.ts +184 -0
- package/attestation.js +260 -0
- package/describe.d.ts +8 -0
- package/describe.js +15 -0
- package/dsse.d.ts +146 -0
- package/dsse.js +258 -0
- package/key.d.ts +134 -0
- package/key.js +208 -0
- package/package.json +42 -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 +353 -0
- package/retirement.js +587 -0
- package/statement.d.ts +78 -0
- package/statement.js +73 -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/read-file.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a text file, returning a named failure instead of throwing.
|
|
3
|
+
*
|
|
4
|
+
* The same reasoning as `readAttestationFile`: a path the operator typed wrong must
|
|
5
|
+
* produce "cannot read that file", not a stack trace from `node:fs`. Verify is usually
|
|
6
|
+
* the first thing anyone runs on a fresh machine, with paths they were given.
|
|
7
|
+
*/
|
|
8
|
+
export type TextRead = {
|
|
9
|
+
ok: true;
|
|
10
|
+
text: string;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
check: "key_unreadable";
|
|
14
|
+
message: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function readTextFile(path: string): TextRead;
|
|
17
|
+
/**
|
|
18
|
+
* Reads a file as raw bytes, for the patch.
|
|
19
|
+
*
|
|
20
|
+
* Separate from `readTextFile` because the patch digest is over bytes. Decoding to a
|
|
21
|
+
* string and re-encoding would go through UTF-8 a second time, and a patch is not
|
|
22
|
+
* guaranteed to be valid UTF-8 - the digest would then be of different bytes than the
|
|
23
|
+
* ones that were approved, and an honest file would report a mismatch.
|
|
24
|
+
*/
|
|
25
|
+
export type BinaryRead = {
|
|
26
|
+
ok: true;
|
|
27
|
+
bytes: Buffer;
|
|
28
|
+
} | {
|
|
29
|
+
ok: false;
|
|
30
|
+
check: "patch_unreadable";
|
|
31
|
+
message: string;
|
|
32
|
+
};
|
|
33
|
+
export declare function readBinaryFile(path: string): BinaryRead;
|
package/read-file.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File reads that return a named failure instead of throwing.
|
|
3
|
+
*
|
|
4
|
+
* Split from `attest.ts` for the same reason as `verify.ts`: the verifier needs these and
|
|
5
|
+
* nothing else in that module needs the engine.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
export function readTextFile(path) {
|
|
9
|
+
try {
|
|
10
|
+
return { ok: true, text: readFileSync(path, "utf8") };
|
|
11
|
+
}
|
|
12
|
+
catch (cause) {
|
|
13
|
+
const code = cause.code ?? "unknown";
|
|
14
|
+
return { ok: false, check: "key_unreadable", message: `Cannot read ${path} (${code}).` };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function readBinaryFile(path) {
|
|
18
|
+
try {
|
|
19
|
+
return { ok: true, bytes: readFileSync(path) };
|
|
20
|
+
}
|
|
21
|
+
catch (cause) {
|
|
22
|
+
const code = cause.code ?? "unknown";
|
|
23
|
+
return { ok: false, check: "patch_unreadable", message: `Cannot read ${path} (${code}).` };
|
|
24
|
+
}
|
|
25
|
+
}
|
package/record.d.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of the signed approval predicate, declared once.
|
|
3
|
+
*
|
|
4
|
+
* These interfaces lived in `predicate.ts`, beside the code that *builds* them. That was
|
|
5
|
+
* wrong for one reason: `attestation.ts` (the verifier) needs to name the type, and a type
|
|
6
|
+
* import still makes the compiler load the module it names. `predicate.ts` imports the
|
|
7
|
+
* gates, the pipeline state and the provenance store, so verifying a record dragged the
|
|
8
|
+
* whole engine into the program — and, in the published verifier, into the closure.
|
|
9
|
+
*
|
|
10
|
+
* Declaring them here means the verifier names nothing outside the package at all — not
|
|
11
|
+
* even in a type import. That matters twice over: a type import still makes the compiler
|
|
12
|
+
* load the named module while building, and it writes the name into the emitted `.d.ts`,
|
|
13
|
+
* where a consumer who installed only this package would see `any` or a typecheck error.
|
|
14
|
+
*
|
|
15
|
+
* `StageId` and `GateKind` therefore carry local declarations rather than imports, and
|
|
16
|
+
* `engine/test/verify-standalone.test.ts` asserts they still agree with the contract.
|
|
17
|
+
*
|
|
18
|
+
* `predicate.ts` re-exports everything in this file, so existing imports are unchanged.
|
|
19
|
+
* There is still exactly one declaration of the predicate: this one.
|
|
20
|
+
*/
|
|
21
|
+
export type StageId = "intake" | "research" | "plan" | "build" | "review" | "demo" | "notify" | "handoff";
|
|
22
|
+
export type GateKind = "operator" | "engineer";
|
|
23
|
+
/** What the record says about one gate. */
|
|
24
|
+
export interface PredicateGate {
|
|
25
|
+
stage: StageId;
|
|
26
|
+
kind: GateKind;
|
|
27
|
+
/** The decision, or null if the gate was never decided. */
|
|
28
|
+
decision: "approve" | "reject" | null;
|
|
29
|
+
gate_id: string;
|
|
30
|
+
/**
|
|
31
|
+
* The artifact this gate decided, or null at a decision-only stage.
|
|
32
|
+
*
|
|
33
|
+
* HANDOFF produces nothing, so there is nothing to point at. Null says that
|
|
34
|
+
* plainly; a placeholder id would claim an artifact that never existed.
|
|
35
|
+
*/
|
|
36
|
+
artifact_id: string | null;
|
|
37
|
+
requested_at: string;
|
|
38
|
+
decided_at: string | null;
|
|
39
|
+
principal_id: string | null;
|
|
40
|
+
principal_role: string | null;
|
|
41
|
+
authenticated_via: string | null;
|
|
42
|
+
/** Which explainer the approver acknowledged. Invariant 5, recorded. */
|
|
43
|
+
acknowledged_explainer_id: string | null;
|
|
44
|
+
explainer_view: string | null;
|
|
45
|
+
}
|
|
46
|
+
/** One stage in the pipeline that did not run. */
|
|
47
|
+
export interface PredicateNotRun {
|
|
48
|
+
stage: StageId;
|
|
49
|
+
/**
|
|
50
|
+
* Why, in the words that were shown when the stage was stepped over. Copied from
|
|
51
|
+
* the `stage_unavailable` record's explainer, never rewritten for the signature: a
|
|
52
|
+
* reason that reads differently in the record than in the log is a reason a reader
|
|
53
|
+
* cannot check.
|
|
54
|
+
*/
|
|
55
|
+
reason: string;
|
|
56
|
+
}
|
|
57
|
+
/** One override, with the per-finding reasons exactly as they were recorded. */
|
|
58
|
+
export interface PredicateOverride {
|
|
59
|
+
override_id: string;
|
|
60
|
+
stage: StageId;
|
|
61
|
+
overridden_verdict: "changes_requested";
|
|
62
|
+
reviewer_artifact_id: string;
|
|
63
|
+
reviewer_principal_id: string;
|
|
64
|
+
reason: string;
|
|
65
|
+
finding_reasons: Array<{
|
|
66
|
+
finding_id: string;
|
|
67
|
+
reason: string;
|
|
68
|
+
}>;
|
|
69
|
+
/** Who set the verdict aside, and when. */
|
|
70
|
+
principal_id: string | null;
|
|
71
|
+
decided_at: string | null;
|
|
72
|
+
/** The provenance record carrying it, so a reader can find it in the chain. */
|
|
73
|
+
record_id: string;
|
|
74
|
+
}
|
|
75
|
+
/** A model that took part, and what it was, exactly as provenance recorded it. */
|
|
76
|
+
export interface PredicateModel {
|
|
77
|
+
stage: StageId;
|
|
78
|
+
adapter: string;
|
|
79
|
+
provider: string;
|
|
80
|
+
model: string;
|
|
81
|
+
mocked: boolean;
|
|
82
|
+
prompt_version: string | null;
|
|
83
|
+
}
|
|
84
|
+
export interface ApprovalPredicate {
|
|
85
|
+
/** The name and version of this predicate, so a reader can find the definition. */
|
|
86
|
+
predicate_name: "imbas.approval";
|
|
87
|
+
predicate_version: "1";
|
|
88
|
+
schema_version: string;
|
|
89
|
+
job_id: string;
|
|
90
|
+
job_title: string;
|
|
91
|
+
sample: boolean;
|
|
92
|
+
created_at: string;
|
|
93
|
+
completed_at: string;
|
|
94
|
+
/** Every gate in the job, in pipeline order. */
|
|
95
|
+
gates: PredicateGate[];
|
|
96
|
+
/** Every override, in the order they were recorded. */
|
|
97
|
+
overrides: PredicateOverride[];
|
|
98
|
+
/**
|
|
99
|
+
* Every stage in the pipeline that did not run, and why, copied from its
|
|
100
|
+
* `stage_unavailable` record.
|
|
101
|
+
*
|
|
102
|
+
* Signed rather than only logged, because the claim this record makes is "production
|
|
103
|
+
* was authorised for what was actually checked". A job that reached the engineer gate
|
|
104
|
+
* with DEMO and NOTIFY unexecuted is still a legitimate job — but a reader of the
|
|
105
|
+
* record must be able to see the gap without also being trusted to have read the
|
|
106
|
+
* chain. Empty means every stage ran; it is never derived by subtracting from the
|
|
107
|
+
* list of stages that happened to produce an artifact.
|
|
108
|
+
*/
|
|
109
|
+
stages_not_run: PredicateNotRun[];
|
|
110
|
+
/** Every adapter that produced an artifact, in the order they ran. */
|
|
111
|
+
models: PredicateModel[];
|
|
112
|
+
/** The head of the provenance chain. Verifying the chain verifies this many records. */
|
|
113
|
+
provenance: {
|
|
114
|
+
head_hash: string;
|
|
115
|
+
record_count: number;
|
|
116
|
+
chain_valid: boolean;
|
|
117
|
+
};
|
|
118
|
+
/** The versions the record was produced under, so a reader can place it in time. */
|
|
119
|
+
versions: {
|
|
120
|
+
contracts: string;
|
|
121
|
+
glossary: string;
|
|
122
|
+
};
|
|
123
|
+
/** The installation whose key signed this. */
|
|
124
|
+
installation_keyid: string;
|
|
125
|
+
proves: string[];
|
|
126
|
+
does_not_prove: string[];
|
|
127
|
+
}
|
package/record.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/retirement.d.ts
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { type DsseEnvelope } from "./dsse.js";
|
|
2
|
+
import { type InstallationKey } from "./key.js";
|
|
3
|
+
/** One retirement, as it appears in the list. */
|
|
4
|
+
export interface KeyRetirement {
|
|
5
|
+
keyid: string;
|
|
6
|
+
/** ISO 8601. Records signed at or after this moment are the alarming ones. */
|
|
7
|
+
retired_at: string;
|
|
8
|
+
/** Why. Free text: "laptop stolen", "machine decommissioned". */
|
|
9
|
+
reason: string;
|
|
10
|
+
/** When the entry was written. Equal to `retired_at` for a timely retirement. */
|
|
11
|
+
recorded_at: string;
|
|
12
|
+
/**
|
|
13
|
+
* The keyid that replaces this one, when there is one.
|
|
14
|
+
*
|
|
15
|
+
* A **note**, not a proof. It says the operator intends that key to be used from here on;
|
|
16
|
+
* it says nothing about whether that key is trustworthy, whether the holder is the same
|
|
17
|
+
* person, or whether it was ever used. The only thing that makes the note worth reading is
|
|
18
|
+
* the policy key's signature over the whole list.
|
|
19
|
+
*
|
|
20
|
+
* Optional on purpose. A laptop that was stolen has no successor, and making the field
|
|
21
|
+
* required would turn "the key is gone" into a malformed entry. `undefined` and
|
|
22
|
+
* "superseded by B" are different facts and both are legitimate.
|
|
23
|
+
*/
|
|
24
|
+
supersedes?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface RetirementList {
|
|
27
|
+
schema_version: string;
|
|
28
|
+
retirements: KeyRetirement[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A retirement list and the signature over it.
|
|
32
|
+
*
|
|
33
|
+
* The signature covers `entries` — the object holding `retirements` — and **not** the
|
|
34
|
+
* envelope that carries it, exactly as an attestation's signature covers the statement
|
|
35
|
+
* rather than the file. What a verifier must be able to establish is "these are the entries
|
|
36
|
+
* the policy key signed", and nothing about how the file was laid out.
|
|
37
|
+
*
|
|
38
|
+
* The payload is serialised once by `sealList` and those bytes are what travel. A verifier
|
|
39
|
+
* re-serialising the entries to check the signature would be checking a document that
|
|
40
|
+
* happened to mean the same thing, which is the failure the counter-signature slice spent
|
|
41
|
+
* its whole length avoiding.
|
|
42
|
+
*/
|
|
43
|
+
export interface SignedRetirementList {
|
|
44
|
+
/** The entries, exactly as signed. */
|
|
45
|
+
entries: {
|
|
46
|
+
retirements: KeyRetirement[];
|
|
47
|
+
schema_version: string;
|
|
48
|
+
};
|
|
49
|
+
/** The DSSE envelope over the canonical bytes of `entries`; null when unsigned. */
|
|
50
|
+
envelope: DsseEnvelope | null;
|
|
51
|
+
}
|
|
52
|
+
/** What a verifier concluded about the list's own integrity. */
|
|
53
|
+
export type ListSignatureState =
|
|
54
|
+
/** The list carries an envelope and it verifies against the policy key supplied. */
|
|
55
|
+
{
|
|
56
|
+
state: "signed";
|
|
57
|
+
keyid: string;
|
|
58
|
+
}
|
|
59
|
+
/** The list is a bare list of entries with no envelope at all. */
|
|
60
|
+
| {
|
|
61
|
+
state: "unsigned";
|
|
62
|
+
}
|
|
63
|
+
/** An envelope is present but does not verify — the entries are not the signed ones. */
|
|
64
|
+
| {
|
|
65
|
+
state: "signature_invalid";
|
|
66
|
+
message: string;
|
|
67
|
+
}
|
|
68
|
+
/** The envelope verifies against a keyid the verifier supplied no key for. */
|
|
69
|
+
| {
|
|
70
|
+
state: "unknown_signer";
|
|
71
|
+
keyid: string;
|
|
72
|
+
}
|
|
73
|
+
/** An envelope is present, but the verifier supplied no policy key to check it with. */
|
|
74
|
+
| {
|
|
75
|
+
state: "unverified_signature";
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* The version this code writes.
|
|
79
|
+
*
|
|
80
|
+
* 2.0.0 added `supersedes`. It is a major bump rather than a minor one because the entries
|
|
81
|
+
* are **signed**: a list written at 1.0.0 and a list written at 2.0.0 are different
|
|
82
|
+
* documents, and a verifier re-serialising one to check the other would produce bytes that
|
|
83
|
+
* never existed. Calling that a minor change would invite exactly that mistake.
|
|
84
|
+
*/
|
|
85
|
+
export declare const RETIREMENT_SCHEMA_VERSION = "2.0.0";
|
|
86
|
+
/** The payload type for a retirement list. Distinct from an attestation's, so a list can
|
|
87
|
+
* never be presented as a record or the other way round. */
|
|
88
|
+
export declare const RETIREMENT_PAYLOAD_TYPE = "application/vnd.imbas.retirements+json";
|
|
89
|
+
export declare class RetirementError extends Error {
|
|
90
|
+
readonly code: string;
|
|
91
|
+
constructor(code: string, message: string);
|
|
92
|
+
}
|
|
93
|
+
/** Where the retirement list lives inside a data dir. */
|
|
94
|
+
export declare function retirementsPath(dataDir: string): string;
|
|
95
|
+
/**
|
|
96
|
+
* Reads a retirement list, or returns an empty one when the file does not exist.
|
|
97
|
+
*
|
|
98
|
+
* **A missing file is an empty list, and that is not the same as "nothing is retired".**
|
|
99
|
+
* The caller must be able to tell those apart, so this returns `found` alongside the list
|
|
100
|
+
* and `lookupRetirement` reports `unknown` rather than `live` when the keyid is absent.
|
|
101
|
+
* Collapsing the two would make every verifier who was never handed a list report a
|
|
102
|
+
* confident "key not retired", which is the exact overclaim this whole module exists to
|
|
103
|
+
* avoid.
|
|
104
|
+
*/
|
|
105
|
+
export type RetirementRead = {
|
|
106
|
+
ok: true;
|
|
107
|
+
list: RetirementList;
|
|
108
|
+
found: boolean;
|
|
109
|
+
/**
|
|
110
|
+
* The signature state, when the file carried an envelope. `null` means the file was a
|
|
111
|
+
* bare list with nothing to check — which `verifyListSignature` reports as `unsigned`.
|
|
112
|
+
*
|
|
113
|
+
* Carried here rather than checked here: this function's job is to read a file, and
|
|
114
|
+
* checking a signature needs a policy key the reader may not have. It reports what it
|
|
115
|
+
* saw so the caller can decide.
|
|
116
|
+
*/
|
|
117
|
+
envelope: DsseEnvelope | null;
|
|
118
|
+
} | {
|
|
119
|
+
ok: false;
|
|
120
|
+
code: "retirements_json" | "retirements_unreadable";
|
|
121
|
+
message: string;
|
|
122
|
+
};
|
|
123
|
+
export declare function readRetirementList(path: string): RetirementRead;
|
|
124
|
+
/**
|
|
125
|
+
* Validates a parsed retirement list.
|
|
126
|
+
*
|
|
127
|
+
* Strict about shape and lenient about nothing, because the failure mode of a
|
|
128
|
+
* half-understood retirement list is a verifier that silently checks fewer entries than
|
|
129
|
+
* its author wrote. An entry missing `retired_at` would otherwise become `undefined`, and
|
|
130
|
+
* `new Date(undefined)` is an Invalid Date that compares false against everything — so a
|
|
131
|
+
* malformed entry would read as "retired after all records", or as "retired never",
|
|
132
|
+
* depending on which way the comparison fell. Refusing the file is the only safe answer.
|
|
133
|
+
*/
|
|
134
|
+
export declare function parseRetirementList(value: unknown): {
|
|
135
|
+
ok: true;
|
|
136
|
+
list: RetirementList;
|
|
137
|
+
} | {
|
|
138
|
+
ok: false;
|
|
139
|
+
message: string;
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* True when the text is an ISO 8601 timestamp that names a real instant.
|
|
143
|
+
*
|
|
144
|
+
* `new Date("nonsense")` produces an Invalid Date rather than throwing, and every
|
|
145
|
+
* comparison against an Invalid Date is `false` — including `>=` and `<=`. A timestamp
|
|
146
|
+
* that cannot be compared is worse than a missing one, because it silently answers "no"
|
|
147
|
+
* to every question asked of it.
|
|
148
|
+
*/
|
|
149
|
+
export declare function isIsoDate(text: string): boolean;
|
|
150
|
+
/**
|
|
151
|
+
* Appends a retirement.
|
|
152
|
+
*
|
|
153
|
+
* Refuses a duplicate keyid. Retiring the same key twice would produce a list where an
|
|
154
|
+
* earlier moment and a later one both claim to be the retirement, and a comparison that
|
|
155
|
+
* read the wrong one would call a post-retirement record genuine. The refusal is by name
|
|
156
|
+
* so an operator who runs the command twice learns what happened.
|
|
157
|
+
*
|
|
158
|
+
* Refuses to move a retirement *earlier* as well, for the same reason in the other
|
|
159
|
+
* direction: shortening the trusted period after records have been signed would
|
|
160
|
+
* retroactively disown approvals the operator made in good faith. A later retirement is
|
|
161
|
+
* a widening of trust, and this refuses that too — a list whose entries can be revised is
|
|
162
|
+
* a list a verifier cannot reason about.
|
|
163
|
+
*/
|
|
164
|
+
export declare function appendRetirement(input: {
|
|
165
|
+
path: string;
|
|
166
|
+
keyid: string;
|
|
167
|
+
retiredAt: string;
|
|
168
|
+
reason: string;
|
|
169
|
+
now?: string;
|
|
170
|
+
/** The keyid that replaces this one, when the operator is rotating rather than losing it. */
|
|
171
|
+
supersedes?: string;
|
|
172
|
+
/** The policy key. Required for a signed list; absent writes a bare, unsigned one. */
|
|
173
|
+
policyKey?: InstallationKey;
|
|
174
|
+
}): KeyRetirement;
|
|
175
|
+
/**
|
|
176
|
+
* Serialises the entries once and signs those bytes.
|
|
177
|
+
*
|
|
178
|
+
* `JSON.stringify` without indentation, not the two-space form the file is written with.
|
|
179
|
+
* The signature must cover a canonical byte string so that a verifier can reproduce it from
|
|
180
|
+
* `entries` alone without knowing how the writer chose to format the file. Key order is
|
|
181
|
+
* insertion order, which is deterministic for a given object, and the entries are built in
|
|
182
|
+
* one place — so a re-serialisation by `verifyListSignature` produces the same bytes.
|
|
183
|
+
*/
|
|
184
|
+
export declare function sealList(entries: {
|
|
185
|
+
schema_version: string;
|
|
186
|
+
retirements: KeyRetirement[];
|
|
187
|
+
}, key: InstallationKey): SignedRetirementList;
|
|
188
|
+
/**
|
|
189
|
+
* Checks the signature over a list, before any entry is read.
|
|
190
|
+
*
|
|
191
|
+
* Returns one of five states rather than a boolean, because "there is no signature", "the
|
|
192
|
+
* signature is wrong" and "I was given no key to check it with" are three different facts
|
|
193
|
+
* and only one of them says somebody tampered.
|
|
194
|
+
*
|
|
195
|
+
* `policyPem` is optional. When it is absent a signed list reports
|
|
196
|
+
* `unverified_signature` — a list that *has* a signature nobody checked, which is again
|
|
197
|
+
* different from a list with none. Collapsing those would let an operator hand a verifier
|
|
198
|
+
* an unsigned list and have it read as "unsigned is normal here".
|
|
199
|
+
*/
|
|
200
|
+
export declare function verifyListSignature(list: unknown, policyPem: string | null): {
|
|
201
|
+
ok: true;
|
|
202
|
+
state: ListSignatureState;
|
|
203
|
+
list: RetirementList;
|
|
204
|
+
} | {
|
|
205
|
+
ok: false;
|
|
206
|
+
state: ListSignatureState;
|
|
207
|
+
message: string;
|
|
208
|
+
};
|
|
209
|
+
/** What a verifier can conclude about one keyid. */
|
|
210
|
+
export type RetirementVerdict =
|
|
211
|
+
/** The keyid is in the list, and the record was signed at or after the retirement. */
|
|
212
|
+
{
|
|
213
|
+
state: "retired_before_signing";
|
|
214
|
+
retirement: KeyRetirement;
|
|
215
|
+
}
|
|
216
|
+
/** The keyid is in the list, and the record was signed before the retirement. */
|
|
217
|
+
| {
|
|
218
|
+
state: "retired_after_signing";
|
|
219
|
+
retirement: KeyRetirement;
|
|
220
|
+
}
|
|
221
|
+
/** The keyid is not in the list the verifier holds. Says nothing about trust. */
|
|
222
|
+
| {
|
|
223
|
+
state: "not_listed";
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The record names no signing moment this can be compared against, so the question
|
|
227
|
+
* cannot be answered. Distinct from both other answers: `not_listed` means "I looked
|
|
228
|
+
* and it is absent", this means "I could not look".
|
|
229
|
+
*/
|
|
230
|
+
| {
|
|
231
|
+
state: "no_signing_time";
|
|
232
|
+
retirement: KeyRetirement;
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
235
|
+
* What following the successors from one keyid produced.
|
|
236
|
+
*
|
|
237
|
+
* The two states that must never be confused are **"retired, and nothing replaced it"** and
|
|
238
|
+
* **"never retired at all"** — opposite facts about a key, and collapsing them would let a
|
|
239
|
+
* dead key read as live or a live one as dead.
|
|
240
|
+
*
|
|
241
|
+
* `path` is the walk itself, from the key asked about to the last retirement reached, so a
|
|
242
|
+
* reader can see A → B → C rather than being told a flat answer. Every path ends at a
|
|
243
|
+
* retirement; `state` says *why* the walk stopped there.
|
|
244
|
+
*
|
|
245
|
+
* Nothing here invents trust. A successor is a note the operator wrote; the only thing that
|
|
246
|
+
* makes it worth reading is the policy key's signature over the list it lives in.
|
|
247
|
+
*/
|
|
248
|
+
export type SuccessionChain =
|
|
249
|
+
/** The key asked about is not in the list. This says nothing about whether it is trusted. */
|
|
250
|
+
{
|
|
251
|
+
state: "not_retired";
|
|
252
|
+
keyid: string;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The walk reached a retirement that names no successor — a stolen or decommissioned
|
|
256
|
+
* machine. A `path` of length one is the simple case: this key was retired and nothing
|
|
257
|
+
* replaced it.
|
|
258
|
+
*/
|
|
259
|
+
| {
|
|
260
|
+
state: "retired_without_successor";
|
|
261
|
+
keyid: string;
|
|
262
|
+
path: SuccessionHop[];
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The walk stopped because a retirement names a successor that is **not in this list**, so
|
|
266
|
+
* whether that key was itself retired cannot be said. Not an error: a successor is a note,
|
|
267
|
+
* not a proof, and a note may point outside the document that carries it.
|
|
268
|
+
*/
|
|
269
|
+
| {
|
|
270
|
+
state: "successor_not_listed";
|
|
271
|
+
keyid: string;
|
|
272
|
+
path: SuccessionHop[];
|
|
273
|
+
successor: string;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* The notes lead back to a key already walked. Only reachable from a hand-edited list,
|
|
277
|
+
* since both write paths refuse a self-successor. Reported rather than looped on, because
|
|
278
|
+
* a verifier that hangs on a malformed file is a denial of service of its own.
|
|
279
|
+
*/
|
|
280
|
+
| {
|
|
281
|
+
state: "cycle";
|
|
282
|
+
keyid: string;
|
|
283
|
+
path: SuccessionHop[];
|
|
284
|
+
repeated: string;
|
|
285
|
+
};
|
|
286
|
+
/** One step of a succession walk: a keyid and the retirement record that ended its turn. */
|
|
287
|
+
export type SuccessionHop = {
|
|
288
|
+
keyid: string;
|
|
289
|
+
retirement: KeyRetirement;
|
|
290
|
+
};
|
|
291
|
+
/**
|
|
292
|
+
* Follows the successors from a keyid, to the end of the chain.
|
|
293
|
+
*
|
|
294
|
+
* Read-only: it walks the signed list and invents nothing, signs nothing, and changes no
|
|
295
|
+
* trust. The walk terminates on the first key that is not retired, on a successor the list
|
|
296
|
+
* does not mention, or on a repeat — and reports *which* it was, because they mean
|
|
297
|
+
* different things to a reader deciding whether a signature is still worth anything.
|
|
298
|
+
*
|
|
299
|
+
* It checks no signature. Call `verifyListSignature` first: following an unverified list is
|
|
300
|
+
* following an unsigned claim, which this whole module exists to avoid.
|
|
301
|
+
*/
|
|
302
|
+
export declare function followSuccessors(list: RetirementList, keyid: string): SuccessionChain;
|
|
303
|
+
/**
|
|
304
|
+
* The walk in one sentence.
|
|
305
|
+
*
|
|
306
|
+
* Deliberately talks about *notes* rather than trust. A chain ending at "key C" says the
|
|
307
|
+
* operator wrote down that C replaced B; it does not say C is trusted, or even that it
|
|
308
|
+
* exists. Nothing here is revocation, expiry, or a statement about time.
|
|
309
|
+
*/
|
|
310
|
+
export declare function describeSuccession(chain: SuccessionChain): string;
|
|
311
|
+
/**
|
|
312
|
+
* One sentence per retirement state, in the words the docs use.
|
|
313
|
+
*
|
|
314
|
+
* Shared by the CLI and the API on purpose. Two copies would drift, and the drift would be
|
|
315
|
+
* invisible: an operator reading the terminal and a verifier reading the panel would be
|
|
316
|
+
* told subtly different things about the same record.
|
|
317
|
+
*/
|
|
318
|
+
export declare function describeVerdict(verdict: {
|
|
319
|
+
keyid: string;
|
|
320
|
+
state: "unchecked";
|
|
321
|
+
} | {
|
|
322
|
+
keyid: string;
|
|
323
|
+
state: "not_listed";
|
|
324
|
+
} | {
|
|
325
|
+
keyid: string;
|
|
326
|
+
state: "retired_after_signing";
|
|
327
|
+
retirement: KeyRetirement;
|
|
328
|
+
} | {
|
|
329
|
+
keyid: string;
|
|
330
|
+
state: "no_signing_time";
|
|
331
|
+
retirement: KeyRetirement;
|
|
332
|
+
} | undefined): string;
|
|
333
|
+
/**
|
|
334
|
+
* Decides what the list says about a record signed by this keyid.
|
|
335
|
+
*
|
|
336
|
+
* **The signing moment is the whole record's, not one signature's.** An envelope does not
|
|
337
|
+
* carry a per-signature timestamp, and inventing one would be a claim the format does not
|
|
338
|
+
* make. So the question is asked of the record's *envelope* — the interval from the signed
|
|
339
|
+
* `created_at` to the signed `completed_at` — and the later end is what decides:
|
|
340
|
+
*
|
|
341
|
+
* - A record whose `completed_at` is at or after the retirement is `retired_before_signing`.
|
|
342
|
+
* The key had been disowned by the time the record was sealed, so the signature claims
|
|
343
|
+
* something its own operator had withdrawn.
|
|
344
|
+
* - A record whose `completed_at` is before the retirement is `retired_after_signing`: the
|
|
345
|
+
* key was trusted throughout the job it describes.
|
|
346
|
+
*
|
|
347
|
+
* Using `completed_at` rather than `created_at` is deliberate. A long job that *started*
|
|
348
|
+
* before the retirement and finished after it was signed after the key was disowned. The
|
|
349
|
+
* earlier timestamp would launder exactly the case this feature exists to catch.
|
|
350
|
+
*/
|
|
351
|
+
export declare function judgeRetirement(list: RetirementList, keyid: string, signing: {
|
|
352
|
+
completedAt: string | null;
|
|
353
|
+
}): RetirementVerdict;
|