imbas-verify 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FORMAT.md +155 -0
- package/LICENSE +17 -0
- package/NOTICE +21 -0
- package/README.md +78 -3
- package/args.d.ts +46 -0
- package/args.js +113 -0
- package/attestation.d.ts +184 -0
- package/attestation.js +260 -0
- package/describe.d.ts +8 -0
- package/describe.js +15 -0
- package/dsse.d.ts +146 -0
- package/dsse.js +258 -0
- package/key.d.ts +134 -0
- package/key.js +208 -0
- package/package.json +42 -4
- package/read-file.d.ts +33 -0
- package/read-file.js +25 -0
- package/record.d.ts +127 -0
- package/record.js +1 -0
- package/retirement.d.ts +353 -0
- package/retirement.js +587 -0
- package/statement.d.ts +78 -0
- package/statement.js +73 -0
- package/verify-cli.d.ts +2 -0
- package/verify-cli.js +34 -0
- package/verify.d.ts +6 -0
- package/verify.js +145 -0
package/dsse.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { verifyBytes } from "./key.js";
|
|
2
|
+
/** What a DSSE envelope's payload is. in-toto statements are JSON. */
|
|
3
|
+
export const DSSE_PAYLOAD_TYPE = "application/vnd.in-toto+json";
|
|
4
|
+
/** The in-toto Statement type. */
|
|
5
|
+
export const IN_TOTO_STATEMENT_TYPE = "https://in-toto.io/Statement/v1";
|
|
6
|
+
/** Imbas's own predicate type: an approval. Nothing else claims this URI. */
|
|
7
|
+
export const APPROVAL_PREDICATE_TYPE = "https://somernorth.co.uk/imbas/attestation/approval/v1";
|
|
8
|
+
/**
|
|
9
|
+
* Pre-Authentication Encoding, exactly as the DSSE spec defines it:
|
|
10
|
+
*
|
|
11
|
+
* PAE(type, body) = "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body
|
|
12
|
+
*
|
|
13
|
+
* where SP is one space and LEN is the length in *bytes*, written in ASCII decimal.
|
|
14
|
+
*
|
|
15
|
+
* The length prefixes are not decoration. Without them, ("ab", "c") and ("a", "bc")
|
|
16
|
+
* encode to the same string, so a signature that verified one would verify the other.
|
|
17
|
+
* Lengths in bytes rather than characters for the same reason one level down: a
|
|
18
|
+
* payload with non-ASCII text has more bytes than characters, and counting characters
|
|
19
|
+
* would both short-count and split a UTF-8 sequence.
|
|
20
|
+
*
|
|
21
|
+
* The spec's own test vector is asserted in the tests:
|
|
22
|
+
* PAE("http://example.com/HelloWorld", "hello world")
|
|
23
|
+
* = "DSSEv1 29 http://example.com/HelloWorld 11 hello world"
|
|
24
|
+
*/
|
|
25
|
+
export function pae(payloadType, payload) {
|
|
26
|
+
const typeBytes = Buffer.from(payloadType, "utf8");
|
|
27
|
+
return Buffer.concat([
|
|
28
|
+
Buffer.from(`DSSEv1 ${typeBytes.length} `, "ascii"),
|
|
29
|
+
typeBytes,
|
|
30
|
+
Buffer.from(` ${payload.length} `, "ascii"),
|
|
31
|
+
payload,
|
|
32
|
+
]);
|
|
33
|
+
}
|
|
34
|
+
const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
35
|
+
/**
|
|
36
|
+
* Strict base64 decoding.
|
|
37
|
+
*
|
|
38
|
+
* `Buffer.from(text, "base64")` is lenient: it skips characters it does not
|
|
39
|
+
* recognise and stops at the first invalid one. That is fine for reading a file and
|
|
40
|
+
* wrong for verifying a signature, because a tampered payload that decodes "well
|
|
41
|
+
* enough" would be checked against a signature over different bytes and the failure
|
|
42
|
+
* would look like bad crypto rather than bad encoding. This rejects it outright.
|
|
43
|
+
*/
|
|
44
|
+
export function decodeBase64(text) {
|
|
45
|
+
if (text.length % 4 !== 0 || !BASE64.test(text))
|
|
46
|
+
return null;
|
|
47
|
+
return Buffer.from(text, "base64");
|
|
48
|
+
}
|
|
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
|
+
/**
|
|
112
|
+
* Checks one signature over the given payload, returning the failure or null.
|
|
113
|
+
*
|
|
114
|
+
* Extracted so single- and multi-signature verification cannot drift: a second copy of
|
|
115
|
+
* base64 decoding and PAE construction is a second place to get the length prefixes
|
|
116
|
+
* wrong, and the failure would look like bad crypto rather than a typo.
|
|
117
|
+
*/
|
|
118
|
+
function checkOneSignature(payloadType, payload, signature, publicPem) {
|
|
119
|
+
const sigBytes = decodeBase64(signature.sig);
|
|
120
|
+
if (!sigBytes) {
|
|
121
|
+
return { check: "payload_encoding", message: "The signature is not valid base64." };
|
|
122
|
+
}
|
|
123
|
+
if (!verifyBytes(publicPem, pae(payloadType, payload), sigBytes)) {
|
|
124
|
+
return {
|
|
125
|
+
check: "signature",
|
|
126
|
+
message: "The signature does not verify against this key, so the record has changed since it was signed.",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Verifies every signature on an envelope against the keys supplied.
|
|
133
|
+
*
|
|
134
|
+
* The property that matters: **each signature must verify over the same payload.** DSSE
|
|
135
|
+
* signatures cover PAE(payloadType, payload), so an attacker cannot move a signature from
|
|
136
|
+
* one document to another — but a *reader* can still be misled by counting. This function
|
|
137
|
+
* therefore reports per-signature results rather than a single boolean, so "two
|
|
138
|
+
* signatures, one of which does not verify" is visible instead of rounding to "signed by
|
|
139
|
+
* two parties".
|
|
140
|
+
*
|
|
141
|
+
* `requireKeyids` is the counter-signature check. A reader who needs a second party to
|
|
142
|
+
* have seen the record passes the keyids they require, and a record missing one fails at
|
|
143
|
+
* `counter_signature_required` rather than passing because *a* key signed it.
|
|
144
|
+
*
|
|
145
|
+
* An unknown keyid is `keyid_missing`, not `signature`: "I have no key for this signer"
|
|
146
|
+
* and "this signature is wrong" are different findings, and a verifier holding one of two
|
|
147
|
+
* keys must not be told the record is forged.
|
|
148
|
+
*/
|
|
149
|
+
export function verifyEnvelopeSignatures(envelope, keys, options = {}) {
|
|
150
|
+
/*
|
|
151
|
+
* The expected type defaults to the in-toto statement type, and that default is
|
|
152
|
+
* load-bearing: a verifier checking a signed record must refuse an envelope that carries
|
|
153
|
+
* something else, or a retirement list could be passed off as an approval. Callers that
|
|
154
|
+
* sign a different kind of document pass their own type — and because the type is part of
|
|
155
|
+
* the PAE bytes, choosing it wrongly fails the signature as well as the shape check.
|
|
156
|
+
*/
|
|
157
|
+
const expectedPayloadType = options.expectedPayloadType ?? DSSE_PAYLOAD_TYPE;
|
|
158
|
+
if (envelope.payloadType !== expectedPayloadType) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
payload: null,
|
|
162
|
+
signatures: [],
|
|
163
|
+
satisfied: [],
|
|
164
|
+
check: "payload_type",
|
|
165
|
+
message: `payloadType is "${envelope.payloadType}", expected "${expectedPayloadType}".`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const payload = decodeBase64(envelope.payload);
|
|
169
|
+
if (!payload) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
payload: null,
|
|
173
|
+
signatures: [],
|
|
174
|
+
satisfied: [],
|
|
175
|
+
check: "payload_encoding",
|
|
176
|
+
message: "The payload is not valid base64, so it cannot be the bytes that were signed.",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const byKeyid = new Map(keys.map((key) => [key.keyid, key.publicPem]));
|
|
180
|
+
// An envelope with no signatures must never verify. Without this, every check below is
|
|
181
|
+
// vacuously satisfied — no signature fails, no required keyid is missing because there is
|
|
182
|
+
// nothing to check it against — and the record would pass. The single-signature path had
|
|
183
|
+
// this check and the rewrite lost it, which is exactly the kind of hole a "verify" function
|
|
184
|
+
// must not have.
|
|
185
|
+
if (envelope.signatures.length === 0) {
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
payload,
|
|
189
|
+
signatures: [],
|
|
190
|
+
satisfied: [],
|
|
191
|
+
check: "signature_present",
|
|
192
|
+
message: "The envelope carries no signature, so there is nothing to verify and nothing has been attested.",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const checks = envelope.signatures.map((signature) => {
|
|
196
|
+
const publicPem = byKeyid.get(signature.keyid);
|
|
197
|
+
if (publicPem === undefined) {
|
|
198
|
+
return {
|
|
199
|
+
keyid: signature.keyid,
|
|
200
|
+
ok: false,
|
|
201
|
+
check: "keyid",
|
|
202
|
+
message: `No key was supplied for signer ${signature.keyid.slice(0, 16)}…, so this signature was not checked.`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const failure = checkOneSignature(envelope.payloadType, payload, signature, publicPem);
|
|
206
|
+
return {
|
|
207
|
+
keyid: signature.keyid,
|
|
208
|
+
ok: failure === null,
|
|
209
|
+
check: failure?.check ?? null,
|
|
210
|
+
message: failure?.message ?? null,
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
const satisfied = checks.filter((check) => check.ok).map((check) => check.keyid);
|
|
214
|
+
/*
|
|
215
|
+
* A record the verifier holds no key for is `keyid`, not `signature`.
|
|
216
|
+
*
|
|
217
|
+
* This was the second thing the rewrite lost. Without it, a record signed by another
|
|
218
|
+
* installation reports "the signature does not verify" — which is a different and much
|
|
219
|
+
* more alarming finding than "you were handed the wrong key". The order matters as much
|
|
220
|
+
* as the check: a genuine bad signature must still be called forged even when some
|
|
221
|
+
* signer is unknown, so a forged `signature` is reported before an unknown `keyid`.
|
|
222
|
+
*/
|
|
223
|
+
const broken = checks.find((check) => !check.ok && check.check === "signature");
|
|
224
|
+
if (broken) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
payload,
|
|
228
|
+
signatures: checks,
|
|
229
|
+
satisfied,
|
|
230
|
+
check: "signature",
|
|
231
|
+
message: `The signature from ${broken.keyid.slice(0, 16)}… does not verify. A record carrying a signature nobody can verify is not a record with fewer signatures; it is one somebody has altered.`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (satisfied.length === 0) {
|
|
235
|
+
const unknown = checks[0];
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
payload,
|
|
239
|
+
signatures: checks,
|
|
240
|
+
satisfied,
|
|
241
|
+
check: "keyid",
|
|
242
|
+
message: `Signed by key ${unknown.keyid.slice(0, 16)}…, and no key you supplied matches it. Supply the public key of the installation that signed this record.`,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
for (const required of options.requireKeyids ?? []) {
|
|
246
|
+
if (!satisfied.includes(required)) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
payload,
|
|
250
|
+
signatures: checks,
|
|
251
|
+
satisfied,
|
|
252
|
+
check: "counter_signature_required",
|
|
253
|
+
message: `This record needs a counter-signature from ${required.slice(0, 16)}…, and it does not carry a valid one. One installation's word is not a second opinion.`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return { ok: true, payload, signatures: checks, satisfied };
|
|
258
|
+
}
|
package/key.d.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The installation key.
|
|
3
|
+
*
|
|
4
|
+
* Every signed record Imbas produces is signed by one key pair that belongs to this
|
|
5
|
+
* installation and nowhere else. That is a deliberate limit, not a shortcoming:
|
|
6
|
+
* Sigstore's keyless flow publishes the signer's identity to a public transparency
|
|
7
|
+
* log, and an operator running Imbas on their own machine should not have to announce
|
|
8
|
+
* to the world that they approved a change. So the key is local, the public half is
|
|
9
|
+
* the only thing that ever leaves, and what a signature proves is bounded by that —
|
|
10
|
+
* see `proves` / `does_not_prove` in the predicate.
|
|
11
|
+
*
|
|
12
|
+
* The private key is written once, with the tightest permission the OS offers, and is
|
|
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.
|
|
17
|
+
*/
|
|
18
|
+
import { type KeyObject } from "node:crypto";
|
|
19
|
+
/** Where the installation key lives inside a data dir. */
|
|
20
|
+
export declare function keyDir(dataDir: string): string;
|
|
21
|
+
export declare function privateKeyPath(dataDir: string): string;
|
|
22
|
+
export declare function publicKeyPath(dataDir: string): string;
|
|
23
|
+
/** sha256 of arbitrary bytes, hex. The DER export is bytes, so this is not `ids.sha256`. */
|
|
24
|
+
export declare function sha256Bytes(bytes: Buffer): string;
|
|
25
|
+
/**
|
|
26
|
+
* The keyid: sha256 of the DER SubjectPublicKeyInfo, hex.
|
|
27
|
+
*
|
|
28
|
+
* Over the DER rather than the PEM on purpose. PEM is base64 of the DER with line
|
|
29
|
+
* breaks and a header, so two encoders can produce different PEM text for the same
|
|
30
|
+
* key — and a keyid that changed with the formatting would be useless as an
|
|
31
|
+
* identifier of the key. The DER is the key; the PEM is a way of writing it down.
|
|
32
|
+
*/
|
|
33
|
+
export declare function keyIdOf(publicKey: KeyObject): string;
|
|
34
|
+
/** Reads a keyid from a PEM public key without creating anything. */
|
|
35
|
+
export declare function keyIdOfPem(publicPem: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Reads a keyid, or null when the text is not a public key at all.
|
|
38
|
+
*
|
|
39
|
+
* Verifying is often the first thing anyone runs on a fresh machine, with a key file
|
|
40
|
+
* they were handed and did not inspect. A DER file, a private key, an HTML error page
|
|
41
|
+
* saved by mistake — all of these should produce "that is not a public key" and not a
|
|
42
|
+
* stack trace from node:crypto. The distinction matters: an unhandled exception tells
|
|
43
|
+
* the operator the tool is broken, when the truth is that their input is wrong.
|
|
44
|
+
*/
|
|
45
|
+
export declare function tryKeyIdOfPem(text: string): string | null;
|
|
46
|
+
export declare class KeyError extends Error {
|
|
47
|
+
readonly code: string;
|
|
48
|
+
constructor(code: string, message: string);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The installation key, as the rest of the engine sees it.
|
|
52
|
+
*
|
|
53
|
+
* There is no `privatePem` field, and that is the point: the private key exists only
|
|
54
|
+
* inside the closure these methods share, so no caller can log it, serialise the
|
|
55
|
+
* handle and find it, or return it from an endpoint by spreading the object.
|
|
56
|
+
*/
|
|
57
|
+
export interface InstallationKey {
|
|
58
|
+
/** sha256 of the DER SPKI, hex. The `keyid` in every envelope. */
|
|
59
|
+
readonly keyid: string;
|
|
60
|
+
/** The public key as PEM. The only key material that may leave the process. */
|
|
61
|
+
publicPem(): string;
|
|
62
|
+
/** ECDSA P-256 / SHA-256 over these bytes. DER-encoded, as `sign` returns. */
|
|
63
|
+
signBytes(bytes: Buffer): Buffer;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Verifies a signature against a PEM public key.
|
|
67
|
+
*
|
|
68
|
+
* `dsaEncoding: "der"` is the default and is stated because it is load-bearing:
|
|
69
|
+
* ECDSA signatures have two encodings, DER and IEEE-P1363 ("raw"), and a verifier that
|
|
70
|
+
* guesses wrong reports a valid signature as forged. DER is what `openssl dgst
|
|
71
|
+
* -verify` expects, which is what makes an independent check possible at all.
|
|
72
|
+
*/
|
|
73
|
+
export declare function verifyBytes(publicPem: string, bytes: Buffer, signature: Buffer): boolean;
|
|
74
|
+
/** Builds a handle around a private key already in memory. */
|
|
75
|
+
export declare function handleFor(privateKey: KeyObject, publicPem: string, keyid: string): InstallationKey;
|
|
76
|
+
/**
|
|
77
|
+
* Loads the installation key, generating it on first use.
|
|
78
|
+
*
|
|
79
|
+
* Creating it lazily rather than at install time keeps the promise simple: the key
|
|
80
|
+
* exists when it is first needed, in the data dir, with no separate setup step that
|
|
81
|
+
* can be skipped and leave signing broken later.
|
|
82
|
+
*/
|
|
83
|
+
export declare function loadOrCreateKey(dataDir: string): InstallationKey;
|
|
84
|
+
/**
|
|
85
|
+
* Loads or creates a key pair at explicit paths.
|
|
86
|
+
*
|
|
87
|
+
* Extracted so the policy key — which signs retirement lists and must be a *different*
|
|
88
|
+
* key from the one that signs records — shares one implementation with the installation
|
|
89
|
+
* key rather than a copy. A second copy of the permission dance is a second place for it to
|
|
90
|
+
* drift, and the drift would be invisible on Windows where the mode bits are advisory.
|
|
91
|
+
*/
|
|
92
|
+
export declare function loadOrCreateNamedKey(privatePath: string, publicPath: string): InstallationKey;
|
|
93
|
+
/**
|
|
94
|
+
* The policy key: what signs a retirement list, and nothing else.
|
|
95
|
+
*
|
|
96
|
+
* A second key for one reason, and it is the whole reason this exists. The attack key
|
|
97
|
+
* retirement defends against is a **stolen installation key**. If the retirement list were
|
|
98
|
+
* signed by that same key, the thief could hand out a confident "no keys were ever
|
|
99
|
+
* retired" and clear themselves — the list would prove nothing against exactly the threat
|
|
100
|
+
* it was built for. A policy key that can be kept somewhere else is what makes the list
|
|
101
|
+
* worth signing at all.
|
|
102
|
+
*
|
|
103
|
+
* It is created on first use like the installation key, so an operator who never retires
|
|
104
|
+
* anything never gets one. `docs/EVIDENCE.md` is explicit about the limit this does *not*
|
|
105
|
+
* remove: on a single machine both private keys sit in the same directory, and whoever
|
|
106
|
+
* holds the policy key can sign a false list.
|
|
107
|
+
*/
|
|
108
|
+
export declare function policyKeyDir(dataDir: string): string;
|
|
109
|
+
export declare function policyPrivateKeyPath(dataDir: string): string;
|
|
110
|
+
export declare function policyPublicKeyPath(dataDir: string): string;
|
|
111
|
+
export declare function loadOrCreatePolicyKey(dataDir: string): InstallationKey;
|
|
112
|
+
/** An in-memory key pair, for tests and the demo. Never written to disk. */
|
|
113
|
+
export declare function ephemeralKey(): InstallationKey;
|
|
114
|
+
/**
|
|
115
|
+
* Builds a signing handle from a private key PEM already in memory.
|
|
116
|
+
*
|
|
117
|
+
* The counter-signing path needs this: a second party signs a record with a key that is
|
|
118
|
+
* not this installation's, and `loadOrCreateKey` would be wrong in two ways — it reads the
|
|
119
|
+
* data dir, and it *creates* a key when there is none. A counter-signer who silently
|
|
120
|
+
* generated a fresh key would sign with something nobody has the public half of, and the
|
|
121
|
+
* record would carry a signature no verifier could ever check.
|
|
122
|
+
*
|
|
123
|
+
* Refuses anything that is not a private key, by name, rather than throwing an OpenSSL
|
|
124
|
+
* error. The keyid is derived from the public half of the same key, so a handle can never
|
|
125
|
+
* disagree with itself about which key it is.
|
|
126
|
+
*/
|
|
127
|
+
export type PrivateKeyRead = {
|
|
128
|
+
ok: true;
|
|
129
|
+
key: InstallationKey;
|
|
130
|
+
} | {
|
|
131
|
+
ok: false;
|
|
132
|
+
message: string;
|
|
133
|
+
};
|
|
134
|
+
export declare function handleFromPrivatePem(text: string): PrivateKeyRead;
|
package/key.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The installation key.
|
|
3
|
+
*
|
|
4
|
+
* Every signed record Imbas produces is signed by one key pair that belongs to this
|
|
5
|
+
* installation and nowhere else. That is a deliberate limit, not a shortcoming:
|
|
6
|
+
* Sigstore's keyless flow publishes the signer's identity to a public transparency
|
|
7
|
+
* log, and an operator running Imbas on their own machine should not have to announce
|
|
8
|
+
* to the world that they approved a change. So the key is local, the public half is
|
|
9
|
+
* the only thing that ever leaves, and what a signature proves is bounded by that —
|
|
10
|
+
* see `proves` / `does_not_prove` in the predicate.
|
|
11
|
+
*
|
|
12
|
+
* The private key is written once, with the tightest permission the OS offers, and is
|
|
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.
|
|
17
|
+
*/
|
|
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";
|
|
21
|
+
/** Where the installation key lives inside a data dir. */
|
|
22
|
+
export function keyDir(dataDir) {
|
|
23
|
+
return join(resolve(dataDir), "keys");
|
|
24
|
+
}
|
|
25
|
+
export function privateKeyPath(dataDir) {
|
|
26
|
+
return join(keyDir(dataDir), "installation-key.pem");
|
|
27
|
+
}
|
|
28
|
+
export function publicKeyPath(dataDir) {
|
|
29
|
+
return join(keyDir(dataDir), "installation-key.pub.pem");
|
|
30
|
+
}
|
|
31
|
+
/** sha256 of arbitrary bytes, hex. The DER export is bytes, so this is not `ids.sha256`. */
|
|
32
|
+
export function sha256Bytes(bytes) {
|
|
33
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The keyid: sha256 of the DER SubjectPublicKeyInfo, hex.
|
|
37
|
+
*
|
|
38
|
+
* Over the DER rather than the PEM on purpose. PEM is base64 of the DER with line
|
|
39
|
+
* breaks and a header, so two encoders can produce different PEM text for the same
|
|
40
|
+
* key — and a keyid that changed with the formatting would be useless as an
|
|
41
|
+
* identifier of the key. The DER is the key; the PEM is a way of writing it down.
|
|
42
|
+
*/
|
|
43
|
+
export function keyIdOf(publicKey) {
|
|
44
|
+
return sha256Bytes(publicKey.export({ type: "spki", format: "der" }));
|
|
45
|
+
}
|
|
46
|
+
/** Reads a keyid from a PEM public key without creating anything. */
|
|
47
|
+
export function keyIdOfPem(publicPem) {
|
|
48
|
+
return keyIdOf(createPublicKey(publicPem));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Reads a keyid, or null when the text is not a public key at all.
|
|
52
|
+
*
|
|
53
|
+
* Verifying is often the first thing anyone runs on a fresh machine, with a key file
|
|
54
|
+
* they were handed and did not inspect. A DER file, a private key, an HTML error page
|
|
55
|
+
* saved by mistake — all of these should produce "that is not a public key" and not a
|
|
56
|
+
* stack trace from node:crypto. The distinction matters: an unhandled exception tells
|
|
57
|
+
* the operator the tool is broken, when the truth is that their input is wrong.
|
|
58
|
+
*/
|
|
59
|
+
export function tryKeyIdOfPem(text) {
|
|
60
|
+
try {
|
|
61
|
+
return keyIdOfPem(text);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export class KeyError extends Error {
|
|
68
|
+
code;
|
|
69
|
+
constructor(code, message) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.code = code;
|
|
72
|
+
this.name = "KeyError";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Verifies a signature against a PEM public key.
|
|
77
|
+
*
|
|
78
|
+
* `dsaEncoding: "der"` is the default and is stated because it is load-bearing:
|
|
79
|
+
* ECDSA signatures have two encodings, DER and IEEE-P1363 ("raw"), and a verifier that
|
|
80
|
+
* guesses wrong reports a valid signature as forged. DER is what `openssl dgst
|
|
81
|
+
* -verify` expects, which is what makes an independent check possible at all.
|
|
82
|
+
*/
|
|
83
|
+
export function verifyBytes(publicPem, bytes, signature) {
|
|
84
|
+
let key;
|
|
85
|
+
try {
|
|
86
|
+
key = createPublicKey(publicPem);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
return verify("sha256", bytes, { key, dsaEncoding: "der" }, signature);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// A malformed signature (wrong length, garbage) makes `verify` throw rather than
|
|
96
|
+
// return false. Both mean "not verified", and only one of them is an exception.
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** 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
|
+
/**
|
|
109
|
+
* Loads the installation key, generating it on first use.
|
|
110
|
+
*
|
|
111
|
+
* Creating it lazily rather than at install time keeps the promise simple: the key
|
|
112
|
+
* exists when it is first needed, in the data dir, with no separate setup step that
|
|
113
|
+
* can be skipped and leave signing broken later.
|
|
114
|
+
*/
|
|
115
|
+
export function loadOrCreateKey(dataDir) {
|
|
116
|
+
return loadOrCreateNamedKey(privateKeyPath(dataDir), publicKeyPath(dataDir));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Loads or creates a key pair at explicit paths.
|
|
120
|
+
*
|
|
121
|
+
* Extracted so the policy key — which signs retirement lists and must be a *different*
|
|
122
|
+
* key from the one that signs records — shares one implementation with the installation
|
|
123
|
+
* key rather than a copy. A second copy of the permission dance is a second place for it to
|
|
124
|
+
* drift, and the drift would be invisible on Windows where the mode bits are advisory.
|
|
125
|
+
*/
|
|
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
|
+
/**
|
|
155
|
+
* The policy key: what signs a retirement list, and nothing else.
|
|
156
|
+
*
|
|
157
|
+
* A second key for one reason, and it is the whole reason this exists. The attack key
|
|
158
|
+
* retirement defends against is a **stolen installation key**. If the retirement list were
|
|
159
|
+
* signed by that same key, the thief could hand out a confident "no keys were ever
|
|
160
|
+
* retired" and clear themselves — the list would prove nothing against exactly the threat
|
|
161
|
+
* it was built for. A policy key that can be kept somewhere else is what makes the list
|
|
162
|
+
* worth signing at all.
|
|
163
|
+
*
|
|
164
|
+
* It is created on first use like the installation key, so an operator who never retires
|
|
165
|
+
* anything never gets one. `docs/EVIDENCE.md` is explicit about the limit this does *not*
|
|
166
|
+
* remove: on a single machine both private keys sit in the same directory, and whoever
|
|
167
|
+
* holds the policy key can sign a false list.
|
|
168
|
+
*/
|
|
169
|
+
export function policyKeyDir(dataDir) {
|
|
170
|
+
return join(resolve(dataDir), "keys");
|
|
171
|
+
}
|
|
172
|
+
export function policyPrivateKeyPath(dataDir) {
|
|
173
|
+
return join(policyKeyDir(dataDir), "policy-key.pem");
|
|
174
|
+
}
|
|
175
|
+
export function policyPublicKeyPath(dataDir) {
|
|
176
|
+
return join(policyKeyDir(dataDir), "policy-key.pub.pem");
|
|
177
|
+
}
|
|
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,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imbas-verify",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"imbas-verify": "./verify-cli.js"
|
|
9
|
+
},
|
|
10
|
+
"main": "./verify.js",
|
|
11
|
+
"types": "./verify.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./verify.d.ts",
|
|
15
|
+
"default": "./verify.js"
|
|
16
|
+
},
|
|
17
|
+
"./args": {
|
|
18
|
+
"types": "./args.d.ts",
|
|
19
|
+
"default": "./args.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"*.js",
|
|
24
|
+
"*.d.ts",
|
|
25
|
+
"README.md",
|
|
26
|
+
"FORMAT.md",
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"NOTICE"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"attestation",
|
|
35
|
+
"provenance",
|
|
36
|
+
"supply-chain",
|
|
37
|
+
"dsse",
|
|
38
|
+
"in-toto",
|
|
39
|
+
"verification"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|