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/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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }