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 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, writeFileSync } from "node:fs";
13
- import { APPROVAL_PREDICATE_TYPE, DSSE_PAYLOAD_TYPE, IN_TOTO_STATEMENT_TYPE, counterSignEnvelope, verifyEnvelopeSignatures, } from "./dsse.js";
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
- export declare function signEnvelope(payload: Buffer, key: InstallationKey, payloadType?: string): DsseEnvelope;
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: `loadOrCreateKey` returns a handle whose only outward
15
- * key material is `publicPem()`. The private PEM lives in a closure, so a caller
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: `loadOrCreateKey` returns a handle whose only outward
15
- * key material is `publicPem()`. The private PEM lives in a closure, so a caller
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, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from "node:crypto";
19
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
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.0",
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": "./verify-cli.js"
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 { mkdirSync, readFileSync, writeFileSync } from "node:fs";
38
- import { signEnvelope, verifyEnvelopeSignatures } from "./dsse.js";
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, type DsseEnvelope } from "./dsse.js";
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, signEnvelope, } from "./dsse.js";
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 {