imbas-verify 0.1.0 → 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 -155
- package/README.md +78 -78
- package/attestation.d.ts +0 -6
- package/attestation.js +2 -63
- package/dsse.d.ts +2 -18
- package/dsse.js +15 -62
- package/key.d.ts +7 -9
- package/key.js +9 -75
- package/package.json +3 -2
- package/retirement.d.ts +0 -16
- package/retirement.js +2 -68
- package/statement.d.ts +1 -19
- package/statement.js +1 -18
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
CHANGED
|
@@ -1,155 +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.
|
|
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/README.md
CHANGED
|
@@ -1,78 +1,78 @@
|
|
|
1
|
-
# imbas-verify
|
|
2
|
-
|
|
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.
|
|
1
|
+
# imbas-verify
|
|
2
|
+
|
|
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/attestation.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { APPROVAL_PREDICATE_TYPE, DSSE_PAYLOAD_TYPE, IN_TOTO_STATEMENT_TYPE, type DsseEnvelope } from "./dsse.js";
|
|
2
|
-
import { type InstallationKey } from "./key.js";
|
|
3
2
|
import type { ApprovalPredicate } from "./record.js";
|
|
4
3
|
import { type KeyRetirement, type RetirementList } from "./retirement.js";
|
|
5
4
|
import { type Statement } from "./statement.js";
|
|
@@ -17,7 +16,6 @@ export interface AttestationFile {
|
|
|
17
16
|
counter_signature?: CounterSignatureFile;
|
|
18
17
|
}
|
|
19
18
|
/** Writes an attestation as JSON. Two-space indent, so a human diff is readable. */
|
|
20
|
-
export declare function writeAttestation(file: AttestationFile, path: string): void;
|
|
21
19
|
/** A second party's signature, as it is written beside the envelope. */
|
|
22
20
|
export interface CounterSignatureFile {
|
|
23
21
|
/**
|
|
@@ -46,10 +44,6 @@ export interface CounterSignatureFile {
|
|
|
46
44
|
* Throws rather than returning a result. A caller who asked to counter-sign has already
|
|
47
45
|
* decided to vouch for a record, and a silent failure would leave them believing they had.
|
|
48
46
|
*/
|
|
49
|
-
export declare function counterSign(file: AttestationFile, keys: readonly {
|
|
50
|
-
publicPem: string;
|
|
51
|
-
keyid: string;
|
|
52
|
-
}[], counterKey: InstallationKey): AttestationFile;
|
|
53
47
|
/**
|
|
54
48
|
* Every check a verifier makes, in the order they run.
|
|
55
49
|
*
|
package/attestation.js
CHANGED
|
@@ -9,72 +9,11 @@
|
|
|
9
9
|
* only that the code is self-consistent.
|
|
10
10
|
*/
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
12
|
-
import { readFileSync
|
|
13
|
-
import { APPROVAL_PREDICATE_TYPE, DSSE_PAYLOAD_TYPE, IN_TOTO_STATEMENT_TYPE,
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { APPROVAL_PREDICATE_TYPE, DSSE_PAYLOAD_TYPE, IN_TOTO_STATEMENT_TYPE, verifyEnvelopeSignatures, } from "./dsse.js";
|
|
14
14
|
import { tryKeyIdOfPem } from "./key.js";
|
|
15
15
|
import { judgeRetirement } from "./retirement.js";
|
|
16
16
|
import { readStatement } from "./statement.js";
|
|
17
|
-
/*
|
|
18
|
-
* `attest` lives in `./attest-produce.js`, not here.
|
|
19
|
-
*
|
|
20
|
-
* It is the only function in this module that needs `predicate.ts`, and `predicate.ts`
|
|
21
|
-
* reaches the pipeline, the gate helpers and the provenance store. Keeping it here meant
|
|
22
|
-
* that importing the *verifier* pulled the engine's pipeline into its closure — 10
|
|
23
|
-
* modules against the 5 a verifier actually uses. The verifier is given away on the
|
|
24
|
-
* promise that checking a record needs nothing from Imbas, and this boundary is what keeps
|
|
25
|
-
* that true; `engine/test/verify-standalone.test.ts` asserts it.
|
|
26
|
-
*/
|
|
27
|
-
/** Writes an attestation as JSON. Two-space indent, so a human diff is readable. */
|
|
28
|
-
export function writeAttestation(file, path) {
|
|
29
|
-
writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, "utf8");
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* Adds a counter-signature to an existing record.
|
|
33
|
-
*
|
|
34
|
-
* The order of operations is the security property, and it is not negotiable:
|
|
35
|
-
*
|
|
36
|
-
* 1. verify every existing signature over the payload, and refuse if any fails;
|
|
37
|
-
* 2. decode the payload those signatures actually cover;
|
|
38
|
-
* 3. sign *those bytes*, unchanged.
|
|
39
|
-
*
|
|
40
|
-
* A counter-signer who signed without step 1 would be endorsing a record they had not
|
|
41
|
-
* read, and their signature would add the appearance of a second opinion to a forged
|
|
42
|
-
* document. Step 2 rather than re-serialising the `statement` field is the same lesson as
|
|
43
|
-
* `attest`: the bytes that travel are the bytes that were signed, never a rebuild.
|
|
44
|
-
*
|
|
45
|
-
* Throws rather than returning a result. A caller who asked to counter-sign has already
|
|
46
|
-
* decided to vouch for a record, and a silent failure would leave them believing they had.
|
|
47
|
-
*/
|
|
48
|
-
export function counterSign(file, keys, counterKey) {
|
|
49
|
-
const verified = verifyEnvelopeSignatures(file.envelope, keys);
|
|
50
|
-
if (!verified.ok) {
|
|
51
|
-
throw new Error(`Refusing to counter-sign: the record does not verify first (${verified.check}). ${verified.message}`);
|
|
52
|
-
}
|
|
53
|
-
/*
|
|
54
|
-
* The readable copy must agree with the signed bytes.
|
|
55
|
-
*
|
|
56
|
-
* `statement` exists so a reader need not base64-decode; it is not the signed thing. But
|
|
57
|
-
* a file whose readable copy says one thing while the signature covers another is worse
|
|
58
|
-
* than no file at all — a reader who trusts the readable form has been lied to by a
|
|
59
|
-
* record that verifies. `attest` refuses to write such a file; a counter-signer must
|
|
60
|
-
* refuse to *endorse* one, because their signature would make the lie look vouched for.
|
|
61
|
-
*/
|
|
62
|
-
const signed = readStatement(verified.payload);
|
|
63
|
-
if (!signed.ok || JSON.stringify(signed.statement) !== JSON.stringify(file.statement)) {
|
|
64
|
-
throw new Error("Refusing to counter-sign: the readable `statement` in this file does not match the bytes the signature covers. The file was edited after it was signed, so what a reader sees is not what was attested.");
|
|
65
|
-
}
|
|
66
|
-
// `verified.payload` is the decoded payload that every signature above was checked
|
|
67
|
-
// against, so signing it attests the same bytes rather than a re-serialisation.
|
|
68
|
-
const envelope = counterSignEnvelope(file.envelope, verified.payload, counterKey);
|
|
69
|
-
return {
|
|
70
|
-
...file,
|
|
71
|
-
envelope,
|
|
72
|
-
counter_signature: {
|
|
73
|
-
signers: envelope.signatures.map((signature) => signature.keyid),
|
|
74
|
-
counter_signed_at: new Date().toISOString(),
|
|
75
|
-
},
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
17
|
function isEnvelope(value) {
|
|
79
18
|
if (typeof value !== "object" || value === null)
|
|
80
19
|
return false;
|
package/dsse.d.ts
CHANGED
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DSSE: the envelope around a signed record.
|
|
3
|
-
*
|
|
4
|
-
* The format is not invented here. It is the Dead Simple Signing Envelope from the
|
|
5
|
-
* in-toto attestation spec, because a record whose signature can only be checked by
|
|
6
|
-
* the program that made it is a record nobody else can check. Choosing a published
|
|
7
|
-
* format is what lets a third party verify an Imbas approval with nothing but the
|
|
8
|
-
* public key and `openssl` — no Imbas code, no Imbas trust.
|
|
9
|
-
*
|
|
10
|
-
* The envelope carries a payload (here, an in-toto Statement), a payloadType, and a
|
|
11
|
-
* signature over the *serialised pair* rather than over the payload alone. That
|
|
12
|
-
* distinction is the whole point of PAE: if the signature covered only the payload,
|
|
13
|
-
* an attacker could relabel the payloadType and the same signature would still verify
|
|
14
|
-
* while the bytes meant something different.
|
|
15
|
-
*/
|
|
16
|
-
import type { InstallationKey } from "./key.js";
|
|
17
1
|
/** What a DSSE envelope's payload is. in-toto statements are JSON. */
|
|
18
2
|
export declare const DSSE_PAYLOAD_TYPE = "application/vnd.in-toto+json";
|
|
19
3
|
/** The in-toto Statement type. */
|
|
@@ -69,7 +53,8 @@ export declare function decodeBase64(text: string): Buffer | null;
|
|
|
69
53
|
* afterwards would leave a signature over one document beside a file claiming to be
|
|
70
54
|
* another, and nothing would verify it.
|
|
71
55
|
*/
|
|
72
|
-
|
|
56
|
+
/** The base64 signature over the PAE bytes for a payload. Shared by both signers. */
|
|
57
|
+
/** The base64 signature over the PAE bytes of an in-toto statement. */
|
|
73
58
|
/**
|
|
74
59
|
* Adds a second, independent signature to an envelope that is already signed.
|
|
75
60
|
*
|
|
@@ -90,7 +75,6 @@ export declare function signEnvelope(payload: Buffer, key: InstallationKey, payl
|
|
|
90
75
|
* ambiguous, and an ambiguous answer is worse than a refused request: a reader would
|
|
91
76
|
* count two signatures and conclude two parties.
|
|
92
77
|
*/
|
|
93
|
-
export declare function counterSignEnvelope(envelope: DsseEnvelope, payload: Buffer, key: InstallationKey): DsseEnvelope;
|
|
94
78
|
/**
|
|
95
79
|
* Which check failed, so a verifier can say the first one rather than "invalid".
|
|
96
80
|
*
|
package/dsse.js
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSSE: the envelope around a signed record.
|
|
3
|
+
*
|
|
4
|
+
* The format is not invented here. It is the Dead Simple Signing Envelope from the
|
|
5
|
+
* in-toto attestation spec, because a record whose signature can only be checked by
|
|
6
|
+
* the program that made it is a record nobody else can check. Choosing a published
|
|
7
|
+
* format is what lets a third party verify an Imbas approval with nothing but the
|
|
8
|
+
* public key and `openssl` — no Imbas code, no Imbas trust.
|
|
9
|
+
*
|
|
10
|
+
* The envelope carries a payload (here, an in-toto Statement), a payloadType, and a
|
|
11
|
+
* signature over the *serialised pair* rather than over the payload alone. That
|
|
12
|
+
* distinction is the whole point of PAE: if the signature covered only the payload,
|
|
13
|
+
* an attacker could relabel the payloadType and the same signature would still verify
|
|
14
|
+
* while the bytes meant something different.
|
|
15
|
+
*/
|
|
1
16
|
import { verifyBytes } from "./key.js";
|
|
2
17
|
/** What a DSSE envelope's payload is. in-toto statements are JSON. */
|
|
3
18
|
export const DSSE_PAYLOAD_TYPE = "application/vnd.in-toto+json";
|
|
@@ -46,68 +61,6 @@ export function decodeBase64(text) {
|
|
|
46
61
|
return null;
|
|
47
62
|
return Buffer.from(text, "base64");
|
|
48
63
|
}
|
|
49
|
-
/**
|
|
50
|
-
* Signs a payload, producing a one-signature envelope.
|
|
51
|
-
*
|
|
52
|
-
* `payloadType` defaults to the in-toto statement type because almost every envelope here
|
|
53
|
-
* carries one. The retirement list passes its own: the type is part of the PAE bytes the
|
|
54
|
-
* signature covers, so it must be chosen *before* signing. Relabelling an envelope
|
|
55
|
-
* afterwards would leave a signature over one document beside a file claiming to be
|
|
56
|
-
* another, and nothing would verify it.
|
|
57
|
-
*/
|
|
58
|
-
export function signEnvelope(payload, key, payloadType = DSSE_PAYLOAD_TYPE) {
|
|
59
|
-
return {
|
|
60
|
-
payloadType,
|
|
61
|
-
payload: payload.toString("base64"),
|
|
62
|
-
// ECDSA signatures are DER-encoded here, not IEEE-P1363. Both are legitimate for
|
|
63
|
-
// ECDSA and they are not interchangeable: DER is a tagged ASN.1 SEQUENCE of r and
|
|
64
|
-
// s, P1363 is raw r||s. DER is chosen because it is what `openssl dgst -verify`
|
|
65
|
-
// reads without extra flags, and an independent check that needs Imbas-specific
|
|
66
|
-
// instructions is not independent. `signBytes` pins `dsaEncoding: "der"` to match.
|
|
67
|
-
signatures: [{ keyid: key.keyid, sig: signatureOver(payloadType, payload, key) }],
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
/** The base64 signature over the PAE bytes for a payload. Shared by both signers. */
|
|
71
|
-
function signatureOver(payloadType, payload, key) {
|
|
72
|
-
return key.signBytes(pae(payloadType, payload)).toString("base64");
|
|
73
|
-
}
|
|
74
|
-
/** The base64 signature over the PAE bytes of an in-toto statement. */
|
|
75
|
-
function attestationSignature(payload, key) {
|
|
76
|
-
return signatureOver(DSSE_PAYLOAD_TYPE, payload, key);
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Adds a second, independent signature to an envelope that is already signed.
|
|
80
|
-
*
|
|
81
|
-
* This is what makes one installation's word checkable by another. The payload is not
|
|
82
|
-
* touched — not re-serialised, not reformatted — because the whole value of the first
|
|
83
|
-
* signature is that it covers these exact bytes. A counter-signature over a
|
|
84
|
-
* re-serialised copy would be a signature over a different document that happened to
|
|
85
|
-
* mean the same thing, which is not the same claim.
|
|
86
|
-
*
|
|
87
|
-
* The payload arrives as bytes rather than as the envelope's base64 for one reason: the
|
|
88
|
-
* caller must have already verified the existing signature and decoded it. Adding a
|
|
89
|
-
* signature to bytes nobody has checked would let a counter-signer endorse a forged
|
|
90
|
-
* record, and the parameter shape makes that obligation visible at the call site rather
|
|
91
|
-
* than leaving it to a comment. See `verifyEnvelopeSignatures`, which is how a caller
|
|
92
|
-
* earns the right to call this.
|
|
93
|
-
*
|
|
94
|
-
* Refuses a key that already signed. Two signatures with one keyid make "signed by whom"
|
|
95
|
-
* ambiguous, and an ambiguous answer is worse than a refused request: a reader would
|
|
96
|
-
* count two signatures and conclude two parties.
|
|
97
|
-
*/
|
|
98
|
-
export function counterSignEnvelope(envelope, payload, key) {
|
|
99
|
-
if (envelope.signatures.some((signature) => signature.keyid === key.keyid)) {
|
|
100
|
-
throw new Error(`This envelope is already signed by ${key.keyid.slice(0, 16)}…. A counter-signature has to come from a second key, or it proves nothing.`);
|
|
101
|
-
}
|
|
102
|
-
if (envelope.payload !== payload.toString("base64")) {
|
|
103
|
-
throw new Error("The payload does not match the one the envelope carries, so a signature over it would attest different bytes than the existing signatures cover.");
|
|
104
|
-
}
|
|
105
|
-
return {
|
|
106
|
-
payloadType: envelope.payloadType,
|
|
107
|
-
payload: envelope.payload,
|
|
108
|
-
signatures: [...envelope.signatures, { keyid: key.keyid, sig: attestationSignature(payload, key) }],
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
64
|
/**
|
|
112
65
|
* Checks one signature over the given payload, returning the failure or null.
|
|
113
66
|
*
|
package/key.d.ts
CHANGED
|
@@ -11,9 +11,13 @@
|
|
|
11
11
|
*
|
|
12
12
|
* The private key is written once, with the tightest permission the OS offers, and is
|
|
13
13
|
* never read into a response, a log line, a provenance record or an export. Nothing
|
|
14
|
-
* in this module hands it out: `
|
|
15
|
-
* key material is `publicPem()`. The private PEM lives in a closure, so a
|
|
16
|
-
* cannot find it by spreading the object or serialising it.
|
|
14
|
+
* in this module hands it out: the loader in `signing.ts` returns a handle whose only
|
|
15
|
+
* outward key material is `publicPem()`. The private PEM lives in a closure, so a
|
|
16
|
+
* caller cannot find it by spreading the object or serialising it.
|
|
17
|
+
*
|
|
18
|
+
* This file is compiled into the published verifier, so it holds only what *checking* a
|
|
19
|
+
* signature needs. Key creation and signing live in `signing.ts`, which the verifier does
|
|
20
|
+
* not compile — the tool that checks the evidence must not be able to manufacture it.
|
|
17
21
|
*/
|
|
18
22
|
import { type KeyObject } from "node:crypto";
|
|
19
23
|
/** Where the installation key lives inside a data dir. */
|
|
@@ -72,7 +76,6 @@ export interface InstallationKey {
|
|
|
72
76
|
*/
|
|
73
77
|
export declare function verifyBytes(publicPem: string, bytes: Buffer, signature: Buffer): boolean;
|
|
74
78
|
/** Builds a handle around a private key already in memory. */
|
|
75
|
-
export declare function handleFor(privateKey: KeyObject, publicPem: string, keyid: string): InstallationKey;
|
|
76
79
|
/**
|
|
77
80
|
* Loads the installation key, generating it on first use.
|
|
78
81
|
*
|
|
@@ -80,7 +83,6 @@ export declare function handleFor(privateKey: KeyObject, publicPem: string, keyi
|
|
|
80
83
|
* exists when it is first needed, in the data dir, with no separate setup step that
|
|
81
84
|
* can be skipped and leave signing broken later.
|
|
82
85
|
*/
|
|
83
|
-
export declare function loadOrCreateKey(dataDir: string): InstallationKey;
|
|
84
86
|
/**
|
|
85
87
|
* Loads or creates a key pair at explicit paths.
|
|
86
88
|
*
|
|
@@ -89,7 +91,6 @@ export declare function loadOrCreateKey(dataDir: string): InstallationKey;
|
|
|
89
91
|
* key rather than a copy. A second copy of the permission dance is a second place for it to
|
|
90
92
|
* drift, and the drift would be invisible on Windows where the mode bits are advisory.
|
|
91
93
|
*/
|
|
92
|
-
export declare function loadOrCreateNamedKey(privatePath: string, publicPath: string): InstallationKey;
|
|
93
94
|
/**
|
|
94
95
|
* The policy key: what signs a retirement list, and nothing else.
|
|
95
96
|
*
|
|
@@ -108,9 +109,7 @@ export declare function loadOrCreateNamedKey(privatePath: string, publicPath: st
|
|
|
108
109
|
export declare function policyKeyDir(dataDir: string): string;
|
|
109
110
|
export declare function policyPrivateKeyPath(dataDir: string): string;
|
|
110
111
|
export declare function policyPublicKeyPath(dataDir: string): string;
|
|
111
|
-
export declare function loadOrCreatePolicyKey(dataDir: string): InstallationKey;
|
|
112
112
|
/** An in-memory key pair, for tests and the demo. Never written to disk. */
|
|
113
|
-
export declare function ephemeralKey(): InstallationKey;
|
|
114
113
|
/**
|
|
115
114
|
* Builds a signing handle from a private key PEM already in memory.
|
|
116
115
|
*
|
|
@@ -131,4 +130,3 @@ export type PrivateKeyRead = {
|
|
|
131
130
|
ok: false;
|
|
132
131
|
message: string;
|
|
133
132
|
};
|
|
134
|
-
export declare function handleFromPrivatePem(text: string): PrivateKeyRead;
|
package/key.js
CHANGED
|
@@ -11,13 +11,16 @@
|
|
|
11
11
|
*
|
|
12
12
|
* The private key is written once, with the tightest permission the OS offers, and is
|
|
13
13
|
* never read into a response, a log line, a provenance record or an export. Nothing
|
|
14
|
-
* in this module hands it out: `
|
|
15
|
-
* key material is `publicPem()`. The private PEM lives in a closure, so a
|
|
16
|
-
* cannot find it by spreading the object or serialising it.
|
|
14
|
+
* in this module hands it out: the loader in `signing.ts` returns a handle whose only
|
|
15
|
+
* outward key material is `publicPem()`. The private PEM lives in a closure, so a
|
|
16
|
+
* caller cannot find it by spreading the object or serialising it.
|
|
17
|
+
*
|
|
18
|
+
* This file is compiled into the published verifier, so it holds only what *checking* a
|
|
19
|
+
* signature needs. Key creation and signing live in `signing.ts`, which the verifier does
|
|
20
|
+
* not compile — the tool that checks the evidence must not be able to manufacture it.
|
|
17
21
|
*/
|
|
18
|
-
import { createHash,
|
|
19
|
-
import {
|
|
20
|
-
import { dirname, join, resolve } from "node:path";
|
|
22
|
+
import { createHash, createPublicKey, verify } from "node:crypto";
|
|
23
|
+
import { join, resolve } from "node:path";
|
|
21
24
|
/** Where the installation key lives inside a data dir. */
|
|
22
25
|
export function keyDir(dataDir) {
|
|
23
26
|
return join(resolve(dataDir), "keys");
|
|
@@ -98,13 +101,6 @@ export function verifyBytes(publicPem, bytes, signature) {
|
|
|
98
101
|
}
|
|
99
102
|
}
|
|
100
103
|
/** Builds a handle around a private key already in memory. */
|
|
101
|
-
export function handleFor(privateKey, publicPem, keyid) {
|
|
102
|
-
return {
|
|
103
|
-
keyid,
|
|
104
|
-
publicPem: () => publicPem,
|
|
105
|
-
signBytes: (bytes) => sign("sha256", bytes, { key: privateKey, dsaEncoding: "der" }),
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
104
|
/**
|
|
109
105
|
* Loads the installation key, generating it on first use.
|
|
110
106
|
*
|
|
@@ -112,9 +108,6 @@ export function handleFor(privateKey, publicPem, keyid) {
|
|
|
112
108
|
* exists when it is first needed, in the data dir, with no separate setup step that
|
|
113
109
|
* can be skipped and leave signing broken later.
|
|
114
110
|
*/
|
|
115
|
-
export function loadOrCreateKey(dataDir) {
|
|
116
|
-
return loadOrCreateNamedKey(privateKeyPath(dataDir), publicKeyPath(dataDir));
|
|
117
|
-
}
|
|
118
111
|
/**
|
|
119
112
|
* Loads or creates a key pair at explicit paths.
|
|
120
113
|
*
|
|
@@ -123,34 +116,6 @@ export function loadOrCreateKey(dataDir) {
|
|
|
123
116
|
* key rather than a copy. A second copy of the permission dance is a second place for it to
|
|
124
117
|
* drift, and the drift would be invisible on Windows where the mode bits are advisory.
|
|
125
118
|
*/
|
|
126
|
-
export function loadOrCreateNamedKey(privatePath, publicPath) {
|
|
127
|
-
if (!existsSync(privatePath)) {
|
|
128
|
-
mkdirSync(dirname(privatePath), { recursive: true });
|
|
129
|
-
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
130
|
-
const privatePem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
131
|
-
const publicPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
132
|
-
// Written with 0600 and then chmodded, which is belt and braces on POSIX and
|
|
133
|
-
// mostly a statement of intent on Windows: Windows does not map `chmod` onto a
|
|
134
|
-
// DACL, so `mode` and `chmodSync` are largely ignored there. On Windows the
|
|
135
|
-
// private key's protection comes from the ACL of the containing directory — the
|
|
136
|
-
// user's profile — not from this bit. Saying that plainly is better than implying
|
|
137
|
-
// a guarantee the platform does not give. On a shared Windows machine, securing
|
|
138
|
-
// the data dir is the operator's job and `docs/EVIDENCE.md` says so.
|
|
139
|
-
writeFileSync(privatePath, privatePem, { encoding: "utf8", mode: 0o600 });
|
|
140
|
-
writeFileSync(publicPath, publicPem, { encoding: "utf8", mode: 0o644 });
|
|
141
|
-
try {
|
|
142
|
-
chmodSync(privatePath, 0o600);
|
|
143
|
-
}
|
|
144
|
-
catch {
|
|
145
|
-
// A filesystem that cannot express modes (FAT32, some network shares) must not
|
|
146
|
-
// stop signing from working. The trade-off is documented in docs/EVIDENCE.md.
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
const privateKey = createPrivateKey(readFileSync(privatePath, "utf8"));
|
|
150
|
-
const publicKey = createPublicKey(privateKey);
|
|
151
|
-
const publicPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
152
|
-
return handleFor(privateKey, publicPem, keyIdOf(publicKey));
|
|
153
|
-
}
|
|
154
119
|
/**
|
|
155
120
|
* The policy key: what signs a retirement list, and nothing else.
|
|
156
121
|
*
|
|
@@ -175,34 +140,3 @@ export function policyPrivateKeyPath(dataDir) {
|
|
|
175
140
|
export function policyPublicKeyPath(dataDir) {
|
|
176
141
|
return join(policyKeyDir(dataDir), "policy-key.pub.pem");
|
|
177
142
|
}
|
|
178
|
-
export function loadOrCreatePolicyKey(dataDir) {
|
|
179
|
-
return loadOrCreateNamedKey(policyPrivateKeyPath(dataDir), policyPublicKeyPath(dataDir));
|
|
180
|
-
}
|
|
181
|
-
/** An in-memory key pair, for tests and the demo. Never written to disk. */
|
|
182
|
-
export function ephemeralKey() {
|
|
183
|
-
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
184
|
-
return handleFor(privateKey, publicKey.export({ type: "spki", format: "pem" }).toString(), keyIdOf(publicKey));
|
|
185
|
-
}
|
|
186
|
-
export function handleFromPrivatePem(text) {
|
|
187
|
-
let privateKey;
|
|
188
|
-
try {
|
|
189
|
-
privateKey = createPrivateKey(text);
|
|
190
|
-
}
|
|
191
|
-
catch {
|
|
192
|
-
return {
|
|
193
|
-
ok: false,
|
|
194
|
-
message: "The signing key is not a PEM private key (it does not begin with -----BEGIN PRIVATE KEY----- or -----BEGIN EC PRIVATE KEY-----).",
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
// `createPrivateKey` also accepts a *public* key PEM in some builds, and signing with
|
|
198
|
-
// one fails deep inside OpenSSL. Asking for the private half first turns that into a
|
|
199
|
-
// sentence at the boundary.
|
|
200
|
-
if (privateKey.type !== "private") {
|
|
201
|
-
return {
|
|
202
|
-
ok: false,
|
|
203
|
-
message: `The signing key is a ${privateKey.type} key. Counter-signing needs the private half.`,
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
const publicPem = createPublicKey(privateKey).export({ type: "spki", format: "pem" }).toString();
|
|
207
|
-
return { ok: true, key: handleFor(privateKey, publicPem, tryKeyIdOfPem(publicPem) ?? keyIdOfPem(publicPem)) };
|
|
208
|
-
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imbas-verify",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Check a signed Imbas approval record without the engine. Verifies signatures, the patch digest and the provenance chain head; reads only the files you name.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
-
"imbas-verify": "
|
|
8
|
+
"imbas-verify": "verify-cli.js"
|
|
9
9
|
},
|
|
10
10
|
"main": "./verify.js",
|
|
11
11
|
"types": "./verify.d.ts",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"*.d.ts",
|
|
25
25
|
"README.md",
|
|
26
26
|
"FORMAT.md",
|
|
27
|
+
"CHANGELOG.md",
|
|
27
28
|
"LICENSE",
|
|
28
29
|
"NOTICE"
|
|
29
30
|
],
|
package/retirement.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { type DsseEnvelope } from "./dsse.js";
|
|
2
|
-
import { type InstallationKey } from "./key.js";
|
|
3
2
|
/** One retirement, as it appears in the list. */
|
|
4
3
|
export interface KeyRetirement {
|
|
5
4
|
keyid: string;
|
|
@@ -161,17 +160,6 @@ export declare function isIsoDate(text: string): boolean;
|
|
|
161
160
|
* a widening of trust, and this refuses that too — a list whose entries can be revised is
|
|
162
161
|
* a list a verifier cannot reason about.
|
|
163
162
|
*/
|
|
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
163
|
/**
|
|
176
164
|
* Serialises the entries once and signs those bytes.
|
|
177
165
|
*
|
|
@@ -181,10 +169,6 @@ export declare function appendRetirement(input: {
|
|
|
181
169
|
* insertion order, which is deterministic for a given object, and the entries are built in
|
|
182
170
|
* one place — so a re-serialisation by `verifyListSignature` produces the same bytes.
|
|
183
171
|
*/
|
|
184
|
-
export declare function sealList(entries: {
|
|
185
|
-
schema_version: string;
|
|
186
|
-
retirements: KeyRetirement[];
|
|
187
|
-
}, key: InstallationKey): SignedRetirementList;
|
|
188
172
|
/**
|
|
189
173
|
* Checks the signature over a list, before any entry is read.
|
|
190
174
|
*
|
package/retirement.js
CHANGED
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
* would make the file worthless: a verifier reading the current state could not tell a
|
|
35
35
|
* key that was never retired from one whose retirement was quietly withdrawn.
|
|
36
36
|
*/
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
37
|
+
import { readFileSync } from "node:fs";
|
|
38
|
+
import { verifyEnvelopeSignatures } from "./dsse.js";
|
|
39
39
|
import { tryKeyIdOfPem } from "./key.js";
|
|
40
40
|
import { join, resolve } from "node:path";
|
|
41
41
|
/**
|
|
@@ -266,57 +266,6 @@ export function isIsoDate(text) {
|
|
|
266
266
|
* a widening of trust, and this refuses that too — a list whose entries can be revised is
|
|
267
267
|
* a list a verifier cannot reason about.
|
|
268
268
|
*/
|
|
269
|
-
export function appendRetirement(input) {
|
|
270
|
-
if (!isIsoDate(input.retiredAt)) {
|
|
271
|
-
throw new RetirementError("retired_at_invalid", `"${input.retiredAt}" is not an ISO 8601 timestamp. A retirement that cannot be compared against a record's time is not a retirement.`);
|
|
272
|
-
}
|
|
273
|
-
if (input.reason.trim().length === 0) {
|
|
274
|
-
throw new RetirementError("reason_required", "A retirement needs a reason. It will be read by somebody deciding whether to trust a record.");
|
|
275
|
-
}
|
|
276
|
-
/*
|
|
277
|
-
* Checked here as well as in `parseRetirement`, and this is not duplication for its own
|
|
278
|
-
* sake: the parse would catch it on the *next* read, after the operator had been told the
|
|
279
|
-
* retirement succeeded. A key that succeeded itself would make the chain follow forever,
|
|
280
|
-
* so it has to be refused before the file is written.
|
|
281
|
-
*/
|
|
282
|
-
if (input.supersedes !== undefined && input.supersedes.trim().length === 0) {
|
|
283
|
-
throw new RetirementError("supersedes_invalid", "`supersedes` was given but names no key. Leave it out when the retired key has no successor.");
|
|
284
|
-
}
|
|
285
|
-
if (input.supersedes !== undefined && input.supersedes === input.keyid) {
|
|
286
|
-
throw new RetirementError("supersedes_self", `A key cannot succeed itself (${input.keyid.slice(0, 16)}…). Name the new key, or leave --supersedes out.`);
|
|
287
|
-
}
|
|
288
|
-
const read = readRetirementList(input.path);
|
|
289
|
-
if (!read.ok)
|
|
290
|
-
throw new RetirementError(read.code, read.message);
|
|
291
|
-
const existing = read.list.retirements.find((entry) => entry.keyid === input.keyid);
|
|
292
|
-
if (existing !== undefined) {
|
|
293
|
-
throw new RetirementError("already_retired", `Key ${input.keyid.slice(0, 16)}… is already retired as of ${existing.retired_at} (${existing.reason}). A second entry would leave a reader choosing between two moments.`);
|
|
294
|
-
}
|
|
295
|
-
const retirement = {
|
|
296
|
-
keyid: input.keyid,
|
|
297
|
-
retired_at: input.retiredAt,
|
|
298
|
-
reason: input.reason.trim(),
|
|
299
|
-
recorded_at: input.now ?? new Date().toISOString(),
|
|
300
|
-
...(input.supersedes === undefined ? {} : { supersedes: input.supersedes }),
|
|
301
|
-
};
|
|
302
|
-
const entries = {
|
|
303
|
-
schema_version: read.list.schema_version,
|
|
304
|
-
retirements: [...read.list.retirements, retirement],
|
|
305
|
-
};
|
|
306
|
-
/*
|
|
307
|
-
* The list is rewritten on every retirement, and the signature is therefore over the
|
|
308
|
-
* whole list rather than appended to. That is correct: what a verifier needs is "these
|
|
309
|
-
* entries are what the policy key signed", and a signature per entry would let somebody
|
|
310
|
-
* drop an entry while leaving its neighbours' signatures intact. A single signature over
|
|
311
|
-
* the whole list makes any removal or insertion visible.
|
|
312
|
-
*/
|
|
313
|
-
const signed = input.policyKey
|
|
314
|
-
? sealList(entries, input.policyKey)
|
|
315
|
-
: { entries, envelope: null };
|
|
316
|
-
mkdirSync(join(resolve(input.path), ".."), { recursive: true });
|
|
317
|
-
writeFileSync(resolve(input.path), `${JSON.stringify(signed, null, 2)}\n`, "utf8");
|
|
318
|
-
return retirement;
|
|
319
|
-
}
|
|
320
269
|
/**
|
|
321
270
|
* Serialises the entries once and signs those bytes.
|
|
322
271
|
*
|
|
@@ -326,21 +275,6 @@ export function appendRetirement(input) {
|
|
|
326
275
|
* insertion order, which is deterministic for a given object, and the entries are built in
|
|
327
276
|
* one place — so a re-serialisation by `verifyListSignature` produces the same bytes.
|
|
328
277
|
*/
|
|
329
|
-
export function sealList(entries, key) {
|
|
330
|
-
const payload = Buffer.from(JSON.stringify(entries), "utf8");
|
|
331
|
-
/*
|
|
332
|
-
* The payload type is the list's own, and it is chosen here rather than restamped
|
|
333
|
-
* afterwards. It is part of the PAE bytes the signature covers, so the type must be set
|
|
334
|
-
* when signing; a relabelled envelope would carry a signature over a different document
|
|
335
|
-
* and nothing would verify it.
|
|
336
|
-
*/
|
|
337
|
-
return {
|
|
338
|
-
entries,
|
|
339
|
-
// ECDSA over the PAE bytes, exactly as an attestation signature is, so openssl can
|
|
340
|
-
// check this list with the same command it checks a record with.
|
|
341
|
-
envelope: signEnvelope(payload, key, RETIREMENT_PAYLOAD_TYPE),
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
278
|
/**
|
|
345
279
|
* Checks the signature over a list, before any entry is read.
|
|
346
280
|
*
|
package/statement.d.ts
CHANGED
|
@@ -11,8 +11,7 @@
|
|
|
11
11
|
* and the predicate says so in its own words (step 3). A signed record that overstates
|
|
12
12
|
* what it proves is worse than no record, because it gets believed.
|
|
13
13
|
*/
|
|
14
|
-
import { APPROVAL_PREDICATE_TYPE, DSSE_PAYLOAD_TYPE, IN_TOTO_STATEMENT_TYPE
|
|
15
|
-
import type { InstallationKey } from "./key.js";
|
|
14
|
+
import { APPROVAL_PREDICATE_TYPE, DSSE_PAYLOAD_TYPE, IN_TOTO_STATEMENT_TYPE } from "./dsse.js";
|
|
16
15
|
/** A subject digest: the algorithm and the hex digest of the exact bytes. */
|
|
17
16
|
export interface SubjectDigest {
|
|
18
17
|
sha256: string;
|
|
@@ -39,23 +38,6 @@ export interface Statement<T> {
|
|
|
39
38
|
}
|
|
40
39
|
/** Builds a statement around a predicate. The caller supplies the subject. */
|
|
41
40
|
export declare function buildStatement<T>(subject: Subject, predicate: T): Statement<T>;
|
|
42
|
-
/**
|
|
43
|
-
* Serialises a statement and signs it.
|
|
44
|
-
*
|
|
45
|
-
* The bytes that get signed are the bytes produced here, and those are the bytes that
|
|
46
|
-
* travel in the envelope — not a re-serialisation. Anything that parsed the statement
|
|
47
|
-
* and wrote it back out would reorder keys or change whitespace, and the signature
|
|
48
|
-
* would then cover a slightly different document than the one on disk. So the payload
|
|
49
|
-
* is built once, as a Buffer, and never rebuilt.
|
|
50
|
-
*
|
|
51
|
-
* Key order is insertion order, which `JSON.stringify` preserves for string keys, so
|
|
52
|
-
* the output is deterministic for a given statement — two attestations of the same
|
|
53
|
-
* job produce byte-identical payloads, which makes them comparable.
|
|
54
|
-
*/
|
|
55
|
-
export declare function sealStatement<T>(statement: Statement<T>, key: InstallationKey): {
|
|
56
|
-
payload: Buffer;
|
|
57
|
-
envelope: DsseEnvelope;
|
|
58
|
-
};
|
|
59
41
|
/**
|
|
60
42
|
* Reads a statement back out of verified payload bytes.
|
|
61
43
|
*
|
package/statement.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* and the predicate says so in its own words (step 3). A signed record that overstates
|
|
12
12
|
* what it proves is worse than no record, because it gets believed.
|
|
13
13
|
*/
|
|
14
|
-
import { APPROVAL_PREDICATE_TYPE, DSSE_PAYLOAD_TYPE, IN_TOTO_STATEMENT_TYPE,
|
|
14
|
+
import { APPROVAL_PREDICATE_TYPE, DSSE_PAYLOAD_TYPE, IN_TOTO_STATEMENT_TYPE, } from "./dsse.js";
|
|
15
15
|
/** Builds a statement around a predicate. The caller supplies the subject. */
|
|
16
16
|
export function buildStatement(subject, predicate) {
|
|
17
17
|
return {
|
|
@@ -21,23 +21,6 @@ export function buildStatement(subject, predicate) {
|
|
|
21
21
|
predicate,
|
|
22
22
|
};
|
|
23
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
24
|
export function readStatement(payload) {
|
|
42
25
|
let parsed;
|
|
43
26
|
try {
|