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 ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ All notable changes to `imbas-verify`. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
+ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ `0.x` is deliberate: the record format is still settling, and a `1.0.0` would invite a consumer
7
+ to build against it before it does.
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [0.1.1]
12
+
13
+ ### Changed
14
+
15
+ - **Verify-only build; no signing code shipped.** The package previously included
16
+ `signEnvelope`, `counterSignEnvelope` and every key-creation function, because the verifier
17
+ compiled the same source files the engine does. The tool that checks the evidence could also
18
+ manufacture it, which made "you do not have to trust us" false in the one way that matters.
19
+ Signing now lives in a module the verifier does not compile, and a test asserts that no
20
+ shipped file contains `createSign`, `generateKeyPair`, a private-key loader or a signing
21
+ entry point.
22
+
23
+ Nothing about verification changed: the same records verify, the same tampered ones are
24
+ refused, and the CLI surface is identical.
25
+
26
+ ## [0.1.0]
27
+
28
+ ### Added
29
+
30
+ - First release. Verifies a signed Imbas approval record without the engine: DSSE envelope over
31
+ an in-toto Statement, ECDSA P-256, patch digest, provenance chain head, optional key-retirement
32
+ check.
33
+ - `FORMAT.md` ships inside the package, so a record can be checked with `openssl` alone without
34
+ reaching anything else.
35
+ - Zero runtime dependencies.
package/FORMAT.md ADDED
@@ -0,0 +1,155 @@
1
+ # The Imbas approval record format
2
+
3
+ This document is shipped inside the `imbas-verify` package so that you do not need to trust,
4
+ reach, or have an account with Imbas to check a record. Everything below is enough to write
5
+ an independent verifier. That is the point of publishing it.
6
+
7
+ ## 1. What a record is
8
+
9
+ A record is a **DSSE envelope** (Dead Simple Signing Envelope, from the in-toto attestation
10
+ specification) wrapping an **in-toto Statement v1**. Both formats are published, and `openssl`
11
+ can verify the signature, so checking a record needs a public key and a shell — nothing else.
12
+
13
+ Deliberately not Sigstore keyless signing. That flow publishes the signer's identity to a
14
+ public transparency log, and an operator approving a change on their own machine should not
15
+ have to announce that to the world. The key is local to the installation, and section 6 says
16
+ plainly what that bounds.
17
+
18
+ ## 2. The envelope
19
+
20
+ ```json
21
+ {
22
+ "payloadType": "application/vnd.in-toto+json",
23
+ "payload": "<base64 of the statement JSON>",
24
+ "signatures": [{ "keyid": "<sha256 hex>", "sig": "<base64 DER signature>" }]
25
+ }
26
+ ```
27
+
28
+ - **`payloadType`** is fixed at `application/vnd.in-toto+json`. Check it before anything else.
29
+ - **`payload`** is the statement, base64-encoded. Decode **strictly**. `Buffer.from(x, "base64")`
30
+ silently skips characters it does not recognise, which can verify a tampered payload against
31
+ different bytes and then blame the cryptography for what is really bad encoding.
32
+ - **`keyid`** is the lowercase hex `sha256` of the **DER SubjectPublicKeyInfo** — not of the PEM
33
+ text, and not of the private key.
34
+ - **`sig`** is base64 of a **DER-encoded** ECDSA signature. Not IEEE-P1363 (the fixed `r||s`
35
+ form). Using the wrong one produces a signature that fails to verify for a reason that looks
36
+ like tampering, so if you are writing your own verifier, this is the first thing to check.
37
+ - A record may carry **more than one signature**, for counter-signing. Every signature present
38
+ must be checked; the format's value is that there is more than one, so a verifier that checks
39
+ only `signatures[0]` throws away the reason counter-signing exists.
40
+
41
+ ## 3. PAE — the bytes that are actually signed
42
+
43
+ You do not sign the JSON. You sign the **Pre-Authentication Encoding** of the payload, so that
44
+ the type and the body cannot be swapped apart:
45
+
46
+ ```
47
+ PAE(type, payload) = "DSSEv1" SP len(type) SP type SP len(payload) SP payload
48
+ ```
49
+
50
+ where `SP` is a single space and `len()` is the **byte** length in ASCII decimal. The whole of
51
+ it in JavaScript:
52
+
53
+ ```js
54
+ const payload = Buffer.from(envelope.payload, "base64");
55
+ const type = Buffer.from(envelope.payloadType, "utf8");
56
+ const pae = Buffer.concat([
57
+ Buffer.from(`DSSEv1 ${type.length} `, "ascii"),
58
+ type,
59
+ Buffer.from(` ${payload.length} `, "ascii"),
60
+ payload,
61
+ ]);
62
+ ```
63
+
64
+ `type.length` and `payload.length` are **byte** counts. Using `payload.toString("utf8").length`
65
+ is wrong for any payload containing non-ASCII, and the error it produces is a signature failure
66
+ that looks like an attack.
67
+
68
+ ## 4. The statement
69
+
70
+ The decoded payload is an in-toto Statement:
71
+
72
+ ```json
73
+ {
74
+ "_type": "https://in-toto.io/Statement/v1",
75
+ "subject": [{ "name": "changes.patch", "digest": { "sha256": "<hex>" } }],
76
+ "predicateType": "https://somernorth.co.uk/imbas/approval/v1",
77
+ "predicate": { "...": "the approval" }
78
+ }
79
+ ```
80
+
81
+ - **`subject[0].digest.sha256`** is over the **raw patch bytes**, exactly as stored. Do not
82
+ normalise line endings before hashing: that reports a mismatch for an honest file.
83
+ - **`predicate`** carries the decision: which stage, which gate, who approved, the artifact, the
84
+ explainer shown, and the provenance chain head this approval continues from.
85
+
86
+ The parsed statement must never be trusted before the envelope is known to hold what was
87
+ signed. Otherwise a record can describe one thing and carry another, and both halves look
88
+ individually valid.
89
+
90
+ ## 5. Verifying with `openssl` alone
91
+
92
+ You need the record, the installation's public key PEM, and optionally the patch.
93
+
94
+ ```sh
95
+ # 1. rebuild the signed bytes -> pae.bin, and extract signatures[0].sig -> sig.der
96
+ # (the fifteen lines in section 3, then base64 -d the sig field)
97
+
98
+ # 2. verify
99
+ openssl dgst -sha256 -verify imbas-pub.pem -signature sig.der pae.bin
100
+ ```
101
+
102
+ `Verified OK` and exit 0, or `Verification failure` and exit 1.
103
+
104
+ ```sh
105
+ # 3. the patch digest, if you have the patch
106
+ sha256sum patch.diff # compare with subject[0].digest.sha256
107
+ ```
108
+
109
+ On Windows without coreutils, `sha256sum` ships with Git for Windows beside `openssl` at
110
+ `C:\Program Files\Git\usr\bin\sha256sum.exe`. Any tool that hashes raw bytes agrees.
111
+
112
+ If the `keyid` does not match the key you were given, you are verifying against the wrong key —
113
+ check that before concluding the record was altered. "Wrong key" and "record tampered" are
114
+ different problems with different fixes.
115
+
116
+ ## 6. What a record proves, and what it does not
117
+
118
+ It proves:
119
+
120
+ - **These exact bytes were signed** by the holder of the private key whose public half has the
121
+ quoted `keyid` — the signature is over the PAE bytes, so neither the payload nor the type can
122
+ change undetected.
123
+ - **The patch is the one approved**, if the subject digest matches the file you hold.
124
+ - **The chain head is intact**, if you check it: each record quotes the hash of the one before,
125
+ so an inserted or removed approval breaks the sequence.
126
+ - **The decision that was recorded**, including who approved, at which gate, and what explainer
127
+ they were shown.
128
+
129
+ It does not prove:
130
+
131
+ - **That the change is good.** An approval records a decision, not a judgement about quality.
132
+ A careless approver produces a perfectly valid record.
133
+ - **That the signer is who you think.** The key is local to one installation. Unless you obtained
134
+ the public key through a channel you already trust, a valid signature proves only that *some*
135
+ installation signed it. This is the honest limit of not using a transparency log, and it is
136
+ why the key must be pinned out of band.
137
+ - **That a key was not revoked, unless you say so.** Pass `--retirements` to check. The three
138
+ answers stay distinct: `not listed` means the verifier looked and found nothing; `unchecked`
139
+ means you gave it nothing to look at. Only the first is a fact about the key.
140
+ - **That the record is the only one.** Nothing here makes a record unique or non-repudiable
141
+ across installations.
142
+
143
+ ## 7. What the verifier does not do
144
+
145
+ `imbas-verify` does not contact a network, apply a patch, read a configuration file, or open a
146
+ data directory. It reads the files you name on the command line and nothing else. It has zero
147
+ runtime dependencies: what you install is what is in the tarball.
148
+
149
+ `--require-signer <keyid>` fails unless the named keyid has a valid signature — useful when you
150
+ are given several keys and only some are acceptable for a given change.
151
+
152
+ ## 8. Licence and scope
153
+
154
+ This document and the verifier are Apache-2.0. The engine that **produces** these records is a
155
+ separate product; it is not included, not licensed here, and not required to check one.
package/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Somer North Ltd
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,21 @@
1
+ Imbas Verifier
2
+ Copyright 2026 Somer North Ltd
3
+
4
+ This package is licensed under the Apache License, Version 2.0. See LICENSE.
5
+
6
+ It contains only the modules needed to CHECK a signed Imbas approval record. The
7
+ engine that produces records - the pipeline, the gates, the adapters, the apply
8
+ control - is a separate proprietary product and is not part of this package, is
9
+ not licensed by it, and is not required to use it.
10
+
11
+ The attribution below covers the formats this verifier reads, both of which are
12
+ public specifications implemented from their published descriptions rather than
13
+ copied from another implementation:
14
+
15
+ DSSE (Dead Simple Signing Envelope)
16
+ https://github.com/secure-systems-lab/dsse
17
+
18
+ in-toto attestation framework
19
+ https://github.com/in-toto/attestation
20
+
21
+ No third-party code is vendored. The package has no runtime dependencies.
package/README.md CHANGED
@@ -1,3 +1,78 @@
1
- # Temporary Holding Version
1
+ # imbas-verify
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Check a signed Imbas approval record without Imbas.
4
+
5
+ ```sh
6
+ npx imbas-verify approval.json --key installation-public.pem
7
+ ```
8
+
9
+ ```
10
+ Verified OK
11
+ signed by a1b2c3d4e5f60718
12
+ job job_01HZX...
13
+ subject changes.patch
14
+ sha256 9f2c... (full digest)
15
+ records 14
16
+ patch matches the subject digest
17
+ retirement not listed
18
+ ```
19
+
20
+ Exit code `0` means verified, `1` means a check failed and the first failing check is
21
+ named, `2` means the arguments were wrong. Prose is for people; the exit code is the
22
+ interface.
23
+
24
+ ## What it checks
25
+
26
+ - **The signature** over the DSSE envelope, against the public keys you supply. Every
27
+ signature is checked, not the first one — a counter-signed record's value is that there
28
+ is more than one.
29
+ - **The payload** is the signed bytes, byte for byte. The statement is parsed only after
30
+ the envelope is known to hold what was signed, so a record cannot describe one thing and
31
+ carry another.
32
+ - **The patch digest**, if you pass `--patch`. It hashes the bytes, not a re-encoded
33
+ string.
34
+ - **The provenance chain head** quoted in the record: that it is intact and ends at the
35
+ hash shown.
36
+ - **Key retirement**, if you pass `--retirements`. The three answers are kept distinct:
37
+ `not listed` means the verifier looked and found nothing; `unchecked` means you gave it
38
+ nothing to look at. Only the first is a fact about the key.
39
+
40
+ ## What it does not do
41
+
42
+ It does not contact a network, apply a patch, read a config file, or open a data
43
+ directory. It reads the files you name on the command line and nothing else.
44
+
45
+ It also does not tell you the change is good. An approval records a decision, and what a
46
+ record proves and does not prove is written down in the statement itself.
47
+
48
+ ## Why you do not have to trust us
49
+
50
+ The point of a verifier is that you can run it yourself. Two things make that true here:
51
+
52
+ - **It is small.** Ten source files, all of them the record format. No pipeline, no
53
+ adapters, no gate logic, no configuration.
54
+ - **It has no dependencies.** Zero. Not a smaller dependency tree — none at all. What you
55
+ install is what is in the tarball.
56
+
57
+ The record format is public and implemented from its published specifications, so a
58
+ record can also be checked with `openssl` alone. `FORMAT.md` in this package has the
59
+ byte layout and the exact commands — it ships with the verifier precisely so that you do
60
+ not need to reach anything else to check us.
61
+
62
+ ## Options
63
+
64
+ ```
65
+ --key <file> a public key PEM to verify against. May be repeated.
66
+ --require-signer <keyid> fail unless this keyid has a valid signature
67
+ --retirements <file> the retirement list this verifier holds
68
+ --retirements-key <file> the policy key's public half, to check the list's signature
69
+ --patch <file> also check the record's subject digest against this file
70
+ --quiet print nothing but the failure, if any
71
+ ```
72
+
73
+ ## Licence
74
+
75
+ Apache-2.0. See `LICENSE` and `NOTICE`.
76
+
77
+ This package is the free half of Imbas. The engine that produces these records is a
78
+ separate proprietary product; it is not included, not licensed here, and not needed.
package/args.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The `Args` shape and the parser, shared by every command.
3
+ *
4
+ * Its own module so that `verify.ts` can type its argument without importing
5
+ * `attest.ts`, which would pull the engine back in and defeat the split.
6
+ *
7
+ * `VERIFY_USAGE` and `parseVerifyArgs` are the standalone command's half. They are
8
+ * separate from the engine's `parse` on purpose: the published package must not accept
9
+ * `attest`, `counter-sign` or `key retire`, because those need a signing key and a data
10
+ * dir, and a free verifier that advertises flags it cannot honour is worse than one with a
11
+ * smaller surface.
12
+ */
13
+ export interface Args {
14
+ command: "attest" | "verify" | "counter-sign" | "key-retire" | "key-follow";
15
+ jobId: string | null;
16
+ file: string | null;
17
+ /** One or more public keys: verify checks against all, counter-sign against these. */
18
+ keys: string[];
19
+ signKey: string | null;
20
+ requireSigners: string[];
21
+ retirements: string | null;
22
+ retirementsKey: string | null;
23
+ retiredAt: string | null;
24
+ reason: string | null;
25
+ supersedes: string | null;
26
+ patch: string | null;
27
+ quiet: boolean;
28
+ }
29
+ /** The standalone command's help text. Only what `imbas-verify` can actually do. */
30
+ export declare const VERIFY_USAGE = "Usage:\n imbas-verify <record.json> --key <public.pem> [--key <public.pem> ...]\n [--require-signer <keyid>] [--retirements <file>]\n [--retirements-key <file>] [--patch <file>] [--quiet]\n\nChecks a signed Imbas approval record against one or more public keys.\n\nExit code 0 means verified, 1 means a check failed, 2 means the arguments were wrong.\nThe first check that failed is named, so a script does not have to parse prose.\n\nOptions:\n --key <file> a public key PEM to verify against. May be repeated.\n --require-signer <keyid> fail unless this keyid has a valid signature\n --retirements <file> the retirement list this verifier holds. Without it the answer\n is \"unchecked\", which is not the same as \"not retired\".\n --retirements-key <file> the policy key's public half. Without it a signed list is\n reported as unverified: present but unchecked.\n --patch <file> also check the record's subject digest against this file\n --quiet print nothing but the failure, if any\n -h, --help this message\n\nWhat this does not do: it does not apply the patch, does not contact a network, and does\nnot read a config file or a data directory. It reads the files you name and nothing else.";
31
+ export type ParseResult = {
32
+ ok: true;
33
+ args: Args;
34
+ } | {
35
+ ok: false;
36
+ message: string;
37
+ };
38
+ /**
39
+ * Parses the standalone command's arguments.
40
+ *
41
+ * Returns a message rather than throwing, because every failure here is a usage failure
42
+ * and the caller's job is to print it. `Args.command` is always `"verify"` — the package
43
+ * has one command, and typing it that way keeps `runVerify` shared without the package
44
+ * needing the engine's parser.
45
+ */
46
+ export declare function parseVerifyArgs(argv: readonly string[]): ParseResult;
package/args.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The `Args` shape and the parser, shared by every command.
3
+ *
4
+ * Its own module so that `verify.ts` can type its argument without importing
5
+ * `attest.ts`, which would pull the engine back in and defeat the split.
6
+ *
7
+ * `VERIFY_USAGE` and `parseVerifyArgs` are the standalone command's half. They are
8
+ * separate from the engine's `parse` on purpose: the published package must not accept
9
+ * `attest`, `counter-sign` or `key retire`, because those need a signing key and a data
10
+ * dir, and a free verifier that advertises flags it cannot honour is worse than one with a
11
+ * smaller surface.
12
+ */
13
+ /** The standalone command's help text. Only what `imbas-verify` can actually do. */
14
+ export const VERIFY_USAGE = `Usage:
15
+ imbas-verify <record.json> --key <public.pem> [--key <public.pem> ...]
16
+ [--require-signer <keyid>] [--retirements <file>]
17
+ [--retirements-key <file>] [--patch <file>] [--quiet]
18
+
19
+ Checks a signed Imbas approval record against one or more public keys.
20
+
21
+ Exit code 0 means verified, 1 means a check failed, 2 means the arguments were wrong.
22
+ The first check that failed is named, so a script does not have to parse prose.
23
+
24
+ Options:
25
+ --key <file> a public key PEM to verify against. May be repeated.
26
+ --require-signer <keyid> fail unless this keyid has a valid signature
27
+ --retirements <file> the retirement list this verifier holds. Without it the answer
28
+ is "unchecked", which is not the same as "not retired".
29
+ --retirements-key <file> the policy key's public half. Without it a signed list is
30
+ reported as unverified: present but unchecked.
31
+ --patch <file> also check the record's subject digest against this file
32
+ --quiet print nothing but the failure, if any
33
+ -h, --help this message
34
+
35
+ What this does not do: it does not apply the patch, does not contact a network, and does
36
+ not read a config file or a data directory. It reads the files you name and nothing else.`;
37
+ /**
38
+ * Parses the standalone command's arguments.
39
+ *
40
+ * Returns a message rather than throwing, because every failure here is a usage failure
41
+ * and the caller's job is to print it. `Args.command` is always `"verify"` — the package
42
+ * has one command, and typing it that way keeps `runVerify` shared without the package
43
+ * needing the engine's parser.
44
+ */
45
+ export function parseVerifyArgs(argv) {
46
+ const args = {
47
+ command: "verify",
48
+ jobId: null,
49
+ file: null,
50
+ keys: [],
51
+ signKey: null,
52
+ requireSigners: [],
53
+ retirements: null,
54
+ retirementsKey: null,
55
+ retiredAt: null,
56
+ reason: null,
57
+ supersedes: null,
58
+ patch: null,
59
+ quiet: false,
60
+ };
61
+ let index = 0;
62
+ /** Reads the value after a flag. Returns null when the flag had none. */
63
+ const takeValue = () => {
64
+ index += 1;
65
+ return argv[index] ?? null;
66
+ };
67
+ while (index < argv.length) {
68
+ const argument = argv[index] ?? "";
69
+ if (argument === "--key" ||
70
+ argument === "--require-signer" ||
71
+ argument === "--retirements" ||
72
+ argument === "--retirements-key" ||
73
+ argument === "--patch") {
74
+ const next = takeValue();
75
+ if (next === null)
76
+ return { ok: false, message: `${argument} needs a value.` };
77
+ if (argument === "--key")
78
+ args.keys.push(next);
79
+ else if (argument === "--require-signer")
80
+ args.requireSigners.push(next);
81
+ else if (argument === "--retirements")
82
+ args.retirements = next;
83
+ else if (argument === "--retirements-key")
84
+ args.retirementsKey = next;
85
+ else
86
+ args.patch = next;
87
+ }
88
+ else if (argument === "--quiet") {
89
+ args.quiet = true;
90
+ }
91
+ else if (argument.startsWith("-")) {
92
+ // Named rather than ignored. A mistyped flag that is silently dropped changes what
93
+ // was checked while still printing a verdict.
94
+ return { ok: false, message: `Unknown option ${argument}.` };
95
+ }
96
+ else if (args.file === null) {
97
+ args.file = argument;
98
+ }
99
+ else {
100
+ return { ok: false, message: `Unexpected argument ${argument}.` };
101
+ }
102
+ index += 1;
103
+ }
104
+ if (args.file === null)
105
+ return { ok: false, message: "No record file was given." };
106
+ if (args.keys.length === 0) {
107
+ // Refused rather than defaulted: verifying against no key cannot produce a verdict,
108
+ // and a "Verified OK" line printed without a key would be the worst output this tool
109
+ // could produce.
110
+ return { ok: false, message: "At least one --key is required." };
111
+ }
112
+ return { ok: true, args };
113
+ }
@@ -0,0 +1,178 @@
1
+ import { APPROVAL_PREDICATE_TYPE, DSSE_PAYLOAD_TYPE, IN_TOTO_STATEMENT_TYPE, type DsseEnvelope } from "./dsse.js";
2
+ import type { ApprovalPredicate } from "./record.js";
3
+ import { type KeyRetirement, type RetirementList } from "./retirement.js";
4
+ import { type Statement } from "./statement.js";
5
+ /** A signed record as it is written to disk. */
6
+ export interface AttestationFile {
7
+ /** The DSSE envelope. This is the signed thing; everything else is a convenience. */
8
+ envelope: DsseEnvelope;
9
+ /** The statement, decoded, so a reader need not base64-decode to see what it says. */
10
+ statement: Statement<ApprovalPredicate>;
11
+ /**
12
+ * Present once a second party has signed. Convenience metadata: the signatures that
13
+ * matter are on the envelope, and this field is not covered by any of them. A reader
14
+ * who needs proof of who signed must verify the envelope, never read this.
15
+ */
16
+ counter_signature?: CounterSignatureFile;
17
+ }
18
+ /** Writes an attestation as JSON. Two-space indent, so a human diff is readable. */
19
+ /** A second party's signature, as it is written beside the envelope. */
20
+ export interface CounterSignatureFile {
21
+ /**
22
+ * The keyids of every signature on the envelope, so a reader can see the shape without
23
+ * decoding it. Convenience only: what counts is the envelope's own signatures, and a
24
+ * reader who trusts this list instead of verifying would be trusting the file's prose.
25
+ */
26
+ signers: string[];
27
+ /** When the counter-signature was added. Not the record's time, and not signed. */
28
+ counter_signed_at: string;
29
+ }
30
+ /**
31
+ * Adds a counter-signature to an existing record.
32
+ *
33
+ * The order of operations is the security property, and it is not negotiable:
34
+ *
35
+ * 1. verify every existing signature over the payload, and refuse if any fails;
36
+ * 2. decode the payload those signatures actually cover;
37
+ * 3. sign *those bytes*, unchanged.
38
+ *
39
+ * A counter-signer who signed without step 1 would be endorsing a record they had not
40
+ * read, and their signature would add the appearance of a second opinion to a forged
41
+ * document. Step 2 rather than re-serialising the `statement` field is the same lesson as
42
+ * `attest`: the bytes that travel are the bytes that were signed, never a rebuild.
43
+ *
44
+ * Throws rather than returning a result. A caller who asked to counter-sign has already
45
+ * decided to vouch for a record, and a silent failure would leave them believing they had.
46
+ */
47
+ /**
48
+ * Every check a verifier makes, in the order they run.
49
+ *
50
+ * The order is chosen so the *first* failure names the most specific fact: a record
51
+ * signed by a different installation fails at `keyid`, not at "signature", because
52
+ * "wrong key" and "tampered record" are different problems with different fixes.
53
+ */
54
+ export type VerifyCheck = "file_json" | "key_not_public" | "envelope_shape" | "payload_type" | "payload_encoding" | "signature_present" | "keyid" | "signature" | "statement_json" | "statement_type" | "predicate_type" | "subject_digest"
55
+ /**
56
+ * A signature the verifier has no key for. Distinct from `signature`, because "I hold
57
+ * no key for this signer" and "this signature is forged" send a reader to different
58
+ * places, and only one of them means the record was tampered with.
59
+ */
60
+ | "keyid_missing"
61
+ /**
62
+ * The record carries fewer valid signers than the verifier required. This is the
63
+ * counter-signature check: it fails when one installation's word is all there is.
64
+ */
65
+ | "counter_signature_required"
66
+ /**
67
+ * A signature was made by a key its own operator had already retired.
68
+ *
69
+ * Distinct from `signature`, which means the bytes were altered. This one means the
70
+ * bytes are intact and the *key* had been disowned when they were signed — so the record
71
+ * may be a forgery, or a genuine approval replayed by whoever held the retired key. Both
72
+ * are alarming and neither is "the file was edited".
73
+ */
74
+ | "key_retired_before_signing"
75
+ /** A retirement list was supplied and could not be read. Says nothing about the record. */
76
+ | "retirements_unreadable";
77
+ /** What the retirement list said about one verified signer. */
78
+ export type SignerRetirement = {
79
+ keyid: string;
80
+ state: "unchecked";
81
+ } | {
82
+ keyid: string;
83
+ state: "not_listed";
84
+ } | {
85
+ keyid: string;
86
+ state: "retired_after_signing";
87
+ retirement: KeyRetirement;
88
+ } | {
89
+ keyid: string;
90
+ state: "no_signing_time";
91
+ retirement: KeyRetirement;
92
+ };
93
+ export type VerifyOutcome = {
94
+ ok: true;
95
+ statement: Statement<ApprovalPredicate>;
96
+ /** The first supplied key that signed. Kept for callers that print one signer. */
97
+ keyid: string;
98
+ /** Every signer whose signature verified, in envelope order. */
99
+ signers: string[];
100
+ /**
101
+ * What the retirement list said about each signer, one entry per entry in `signers`.
102
+ *
103
+ * `unchecked` when no list was supplied — which is not the same as `not_listed`,
104
+ * and the difference is the whole reason this is a list of verdicts rather than a
105
+ * boolean. A verifier who was handed nothing must not read as a verifier who
106
+ * checked and found nothing.
107
+ */
108
+ retirements: SignerRetirement[];
109
+ } | {
110
+ ok: false;
111
+ check: VerifyCheck;
112
+ message: string;
113
+ };
114
+ export interface VerifyInputs {
115
+ /** The attestation file's JSON, already read. */
116
+ file: unknown;
117
+ /**
118
+ * The verifier's public keys. Only public material reaches this function.
119
+ *
120
+ * A list rather than one PEM, because a counter-signed record carries more than one
121
+ * signature and a verifier has to be able to check all of them. Checking only the first
122
+ * would report "verified" for a record whose second signature is forged.
123
+ */
124
+ publicPem: string | readonly string[];
125
+ /** The patch file to check the subject digest against, when supplied. */
126
+ patch?: Buffer;
127
+ /**
128
+ * Keyids that must have signed. A verifier reading a record with a counter-signature
129
+ * passes the key they require here, so "one installation's word" fails rather than
130
+ * passing because *a* key signed it.
131
+ */
132
+ requireKeyids?: readonly string[];
133
+ /**
134
+ * The retirement list this verifier holds, if any.
135
+ *
136
+ * Absent means "nobody gave me one", which is reported as `unchecked` and never as
137
+ * "the key is fine". Present means a record signed at or after a retirement is refused
138
+ * at `key_retired_before_signing`; one signed before it still verifies, because the key
139
+ * was trusted when the record was made and saying otherwise would retroactively disown
140
+ * every honest approval the machine ever produced.
141
+ */
142
+ retirements?: RetirementList | null;
143
+ }
144
+ /**
145
+ * Verifies a signed record.
146
+ *
147
+ * The signature check comes before anything is read out of the payload, and that order
148
+ * is the whole security property: statement fields are parsed only after the bytes are
149
+ * known to be the bytes that were signed. Reading first would mean acting on
150
+ * unauthenticated input, which is how a "verify" function ends up trusting a tampered
151
+ * file for the length of one bug.
152
+ */
153
+ export declare function verifyAttestation(inputs: VerifyInputs): VerifyOutcome;
154
+ /**
155
+ * Reads an attestation file, refusing anything that is not JSON.
156
+ *
157
+ * Returns a result rather than throwing, and that is the whole point of the function.
158
+ * `verifyAttestation` already has a `file_json` check for "this is not an object", but
159
+ * a malformed file never reached it: `JSON.parse` threw first, and the operator got a
160
+ * `SyntaxError` stack trace from inside the tool. That is the failure mode this codebase
161
+ * refuses everywhere else — an unhandled exception says the tool is broken, when the
162
+ * truth is that a file is wrong. The same applies to a missing file and to a file that
163
+ * cannot be read at all.
164
+ *
165
+ * Path is included in the message but not the contents: a record could be large, and a
166
+ * verifier reading a wrong file usually needs to know *which* file, not what is in it.
167
+ */
168
+ export type FileRead = {
169
+ ok: true;
170
+ file: unknown;
171
+ } | {
172
+ ok: false;
173
+ check: "file_json" | "file_unreadable";
174
+ message: string;
175
+ };
176
+ export declare function readAttestationFile(path: string): FileRead;
177
+ /** Re-exported so a caller needs one import for the whole flow. */
178
+ export { APPROVAL_PREDICATE_TYPE, DSSE_PAYLOAD_TYPE, IN_TOTO_STATEMENT_TYPE, };