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/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
|
-
#
|
|
1
|
+
# imbas-verify
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|
package/attestation.d.ts
ADDED
|
@@ -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, };
|