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/verify.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { Args } from "./args.js";
2
+ /** Runs verify. Returns 0 or 1, naming the first check that failed. */
3
+ export declare function runVerify(args: Args, writers: {
4
+ out(t: string): void;
5
+ err(t: string): void;
6
+ }): number;
package/verify.js ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The verify command, separated from `attest.ts` so it can be published.
3
+ *
4
+ * `attest.ts` holds five commands, four of which need the engine: the signing key, the
5
+ * data dir, the job store, the pipeline. `verify` needs none of them — it reads a record,
6
+ * a public key and a patch, and answers. Keeping it in the same module meant that
7
+ * importing the verifier pulled `loadConfig`, and `config.ts` reads `.env`. Measured: a
8
+ * child process importing `dist/cli/attest.js` with a `.env` in its cwd gained all 25
9
+ * names from that file.
10
+ *
11
+ * This file is the verifier's entry point and it imports nothing from the engine's
12
+ * configuration, storage or pipeline. Everything it needs is in `evidence/`.
13
+ */
14
+ import { resolve } from "node:path";
15
+ import { readAttestationFile, verifyAttestation, } from "./attestation.js";
16
+ import { tryKeyIdOfPem } from "./key.js";
17
+ import { describeVerdict, readRetirementList, verifyListSignature, } from "./retirement.js";
18
+ import { describeListSignature } from "./describe.js";
19
+ import { readBinaryFile, readTextFile } from "./read-file.js";
20
+ /** Runs verify. Returns 0 or 1, naming the first check that failed. */
21
+ export function runVerify(args, writers) {
22
+ const pems = [];
23
+ for (const path of args.keys) {
24
+ const read = readTextFile(resolve(path));
25
+ if (!read.ok) {
26
+ writers.err(`FAILED at "${read.check}": ${read.message}\n`);
27
+ return 1;
28
+ }
29
+ pems.push(read.text);
30
+ }
31
+ const fileRead = readAttestationFile(resolve(args.file));
32
+ if (!fileRead.ok) {
33
+ writers.err(`FAILED at "${fileRead.check}": ${fileRead.message}\n`);
34
+ return 1;
35
+ }
36
+ const patchRead = args.patch ? readBinaryFile(resolve(args.patch)) : null;
37
+ if (patchRead && !patchRead.ok) {
38
+ writers.err(`FAILED at "${patchRead.check}": ${patchRead.message}\n`);
39
+ return 1;
40
+ }
41
+ const patch = patchRead?.ok ? patchRead.bytes : undefined;
42
+ /*
43
+ * The retirement list is read before verifying, so a list the verifier cannot read is
44
+ * reported as itself rather than as a record failure. A malformed list says nothing
45
+ * about the record, and blaming the record for it would send a reader to the wrong file.
46
+ */
47
+ let retirements = null;
48
+ let listSignature = null;
49
+ if (args.retirements) {
50
+ const listRead = readRetirementList(resolve(args.retirements));
51
+ if (!listRead.ok) {
52
+ writers.err(`FAILED at "${listRead.code}": ${listRead.message}\n`);
53
+ return 1;
54
+ }
55
+ if (!listRead.found) {
56
+ // Named rather than silently empty. An operator who pointed at the wrong path would
57
+ // otherwise get a clean "not listed" verdict about every key in the world.
58
+ writers.err(`FAILED at "retirements_unreadable": ${resolve(args.retirements)} does not exist. A list that was never found cannot say a key is not retired.\n`);
59
+ return 1;
60
+ }
61
+ /*
62
+ * The list's own signature is checked *before* any entry is used.
63
+ *
64
+ * This is the order that matters. Reading entries first and checking the signature
65
+ * afterwards would mean acting on unauthenticated input for the length of one bug —
66
+ * exactly the mistake `verifyAttestation` is built to avoid, and the reason it parses
67
+ * the statement only after the bytes are known to be the signed ones.
68
+ */
69
+ let policyPem = null;
70
+ if (args.retirementsKey) {
71
+ const keyRead = readTextFile(resolve(args.retirementsKey));
72
+ if (!keyRead.ok) {
73
+ writers.err(`FAILED at "${keyRead.check}": ${keyRead.message}\n`);
74
+ return 1;
75
+ }
76
+ if (tryKeyIdOfPem(keyRead.text) === null) {
77
+ writers.err(`FAILED at "key_not_public": ${args.retirementsKey} is not a PEM public key, so the list's signature cannot be checked with it.\n`);
78
+ return 1;
79
+ }
80
+ policyPem = keyRead.text;
81
+ }
82
+ const checked = verifyListSignature(listRead.envelope === null
83
+ ? { retirements: listRead.list.retirements, schema_version: listRead.list.schema_version }
84
+ : { entries: { retirements: listRead.list.retirements, schema_version: listRead.list.schema_version }, envelope: listRead.envelope }, policyPem);
85
+ listSignature = checked.state;
86
+ if (!checked.ok && checked.state.state !== "unverified_signature" && checked.state.state !== "unsigned") {
87
+ // A present-but-wrong signature is a refusal: the entries are not the signed ones, so
88
+ // using them would answer a question about a document nobody attested.
89
+ writers.err(`FAILED at "retirements_signature": ${checked.message}\n`);
90
+ return 1;
91
+ }
92
+ retirements = listRead.list;
93
+ }
94
+ const outcome = verifyAttestation({
95
+ file: fileRead.file,
96
+ publicPem: pems,
97
+ ...(patch ? { patch } : {}),
98
+ ...(args.requireSigners.length ? { requireKeyids: args.requireSigners } : {}),
99
+ retirements,
100
+ });
101
+ if (!outcome.ok) {
102
+ writers.err(`FAILED at "${outcome.check}": ${outcome.message}\n`);
103
+ return 1;
104
+ }
105
+ if (!args.quiet) {
106
+ writers.out("Verified OK\n");
107
+ // Every signer, not only the first: a counter-signed record's value is that there is
108
+ // more than one, and printing one keyid would undo the thing the signature added.
109
+ for (const signer of outcome.signers)
110
+ writers.out(` signed by ${signer}\n`);
111
+ writers.out(` job ${outcome.statement.predicate.job_id}\n`);
112
+ writers.out(` subject ${outcome.statement.subject[0]?.name ?? "(none)"}\n`);
113
+ writers.out(` sha256 ${outcome.statement.subject[0]?.digest.sha256 ?? ""}\n`);
114
+ // Printed only when the signed subject carries one. A verifier needs to know which
115
+ // commit the approval became, and printing a blank or a placeholder would suggest the
116
+ // patch was applied when it was not.
117
+ const commit = outcome.statement.subject[0]?.gitCommit;
118
+ if (commit)
119
+ writers.out(` commit ${commit}\n`);
120
+ writers.out(` records ${outcome.statement.predicate.provenance.record_count}\n`);
121
+ if (args.patch)
122
+ writers.out(" patch matches the subject digest\n");
123
+ /*
124
+ * The retirement answer is printed even when it is "unchecked", because silence reads
125
+ * as "nothing to report". The three states are deliberately not reducible to a tick:
126
+ * `not listed` means the verifier looked and found nothing, `unchecked` means nobody
127
+ * gave them anything to look at, and only the first is a fact about the key.
128
+ */
129
+ for (const verdict of outcome.retirements) {
130
+ writers.out(` retirement ${describeVerdict(verdict)}\n`);
131
+ }
132
+ /*
133
+ * The list's own integrity, printed beside its answer.
134
+ *
135
+ * Without this the verdict above would be presented with the same confidence whether
136
+ * the list that produced it was signed or typed by anybody. `unsigned` and
137
+ * `unverified_signature` are both reported, and neither is a refusal — the record
138
+ * still verifies — but a reader is told what the list is worth.
139
+ */
140
+ if (listSignature !== null) {
141
+ writers.out(` retirements-list ${describeListSignature(listSignature)}\n`);
142
+ }
143
+ }
144
+ return 0;
145
+ }