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/statement.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
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, signEnvelope, } 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
|
+
/**
|
|
25
|
+
* Serialises a statement and signs it.
|
|
26
|
+
*
|
|
27
|
+
* The bytes that get signed are the bytes produced here, and those are the bytes that
|
|
28
|
+
* travel in the envelope — not a re-serialisation. Anything that parsed the statement
|
|
29
|
+
* and wrote it back out would reorder keys or change whitespace, and the signature
|
|
30
|
+
* would then cover a slightly different document than the one on disk. So the payload
|
|
31
|
+
* is built once, as a Buffer, and never rebuilt.
|
|
32
|
+
*
|
|
33
|
+
* Key order is insertion order, which `JSON.stringify` preserves for string keys, so
|
|
34
|
+
* the output is deterministic for a given statement — two attestations of the same
|
|
35
|
+
* job produce byte-identical payloads, which makes them comparable.
|
|
36
|
+
*/
|
|
37
|
+
export function sealStatement(statement, key) {
|
|
38
|
+
const payload = Buffer.from(JSON.stringify(statement, null, 2), "utf8");
|
|
39
|
+
return { payload, envelope: signEnvelope(payload, key) };
|
|
40
|
+
}
|
|
41
|
+
export function readStatement(payload) {
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(payload.toString("utf8"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return { ok: false, check: "statement_json", message: "The payload is not JSON." };
|
|
48
|
+
}
|
|
49
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
50
|
+
return { ok: false, check: "statement_json", message: "The payload is not a JSON object." };
|
|
51
|
+
}
|
|
52
|
+
const statement = parsed;
|
|
53
|
+
if (statement._type !== IN_TOTO_STATEMENT_TYPE) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
check: "statement_type",
|
|
57
|
+
message: `_type is "${String(statement._type)}", expected "${IN_TOTO_STATEMENT_TYPE}".`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (statement.predicateType !== APPROVAL_PREDICATE_TYPE) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
check: "predicate_type",
|
|
64
|
+
message: `predicateType is "${String(statement.predicateType)}", expected "${APPROVAL_PREDICATE_TYPE}".`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (!Array.isArray(statement.subject) || statement.subject.length === 0) {
|
|
68
|
+
return { ok: false, check: "statement_json", message: "The statement names no subject." };
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, statement: statement };
|
|
71
|
+
}
|
|
72
|
+
/** Re-exported so a caller needs one import for the whole format. */
|
|
73
|
+
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
|
+
}
|
package/verify.d.ts
ADDED
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
|
+
}
|