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/attestation.js ADDED
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Making and verifying an attestation, end to end.
3
+ *
4
+ * This is the seam between "a record Imbas can build" and "a file someone else can
5
+ * check". The two halves are deliberately separate functions with no shared state:
6
+ * `attest` has the signing key and never verifies; `verify` has only public material
7
+ * and never signs. A verifier that could reach the signing key would not be a
8
+ * verifier, and a test that used the signing key to check its own output would prove
9
+ * only that the code is self-consistent.
10
+ */
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";
14
+ import { tryKeyIdOfPem } from "./key.js";
15
+ import { judgeRetirement } from "./retirement.js";
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
+ function isEnvelope(value) {
79
+ if (typeof value !== "object" || value === null)
80
+ return false;
81
+ const candidate = value;
82
+ return (typeof candidate.payloadType === "string" &&
83
+ typeof candidate.payload === "string" &&
84
+ Array.isArray(candidate.signatures));
85
+ }
86
+ /**
87
+ * Verifies a signed record.
88
+ *
89
+ * The signature check comes before anything is read out of the payload, and that order
90
+ * is the whole security property: statement fields are parsed only after the bytes are
91
+ * known to be the bytes that were signed. Reading first would mean acting on
92
+ * unauthenticated input, which is how a "verify" function ends up trusting a tampered
93
+ * file for the length of one bug.
94
+ */
95
+ export function verifyAttestation(inputs) {
96
+ const { file, publicPem, patch, requireKeyids } = inputs;
97
+ if (typeof file !== "object" || file === null) {
98
+ return { ok: false, check: "file_json", message: "The file is not a JSON object." };
99
+ }
100
+ const pems = typeof publicPem === "string" ? [publicPem] : [...publicPem];
101
+ if (pems.length === 0) {
102
+ return {
103
+ ok: false,
104
+ check: "key_not_public",
105
+ message: "No public key was supplied, so nothing can be checked.",
106
+ };
107
+ }
108
+ // Every key is checked before the envelope, so a bad key file says what is wrong with
109
+ // the key rather than reporting "signature does not verify" against nothing.
110
+ const keys = [];
111
+ for (const pem of pems) {
112
+ const keyid = tryKeyIdOfPem(pem);
113
+ if (keyid === null) {
114
+ return {
115
+ ok: false,
116
+ check: "key_not_public",
117
+ message: `One of the keys supplied is not a PEM public key (it does not begin with -----BEGIN PUBLIC KEY-----).`,
118
+ };
119
+ }
120
+ keys.push({ publicPem: pem, keyid });
121
+ }
122
+ const envelope = file.envelope;
123
+ if (!isEnvelope(envelope)) {
124
+ return {
125
+ ok: false,
126
+ check: "envelope_shape",
127
+ message: "The file has no DSSE envelope, or the envelope is malformed.",
128
+ };
129
+ }
130
+ /*
131
+ * Every signature, not the first one.
132
+ *
133
+ * A single-signature record takes the same path with a list of one, so there is no
134
+ * second implementation to keep in step. What it buys is the case this whole slice
135
+ * exists for: a counter-signed record whose second signature is wrong fails, rather
136
+ * than passing because the first one happened to verify.
137
+ */
138
+ const verification = verifyEnvelopeSignatures(envelope, keys, {
139
+ requireKeyids: requireKeyids ?? [],
140
+ });
141
+ if (!verification.ok) {
142
+ return { ok: false, check: verification.check, message: verification.message };
143
+ }
144
+ const payload = verification.payload;
145
+ if (payload === null) {
146
+ return { ok: false, check: "payload_encoding", message: "The payload could not be decoded." };
147
+ }
148
+ const read = readStatement(payload);
149
+ if (!read.ok) {
150
+ return { ok: false, check: read.check, message: read.message };
151
+ }
152
+ if (patch !== undefined) {
153
+ const subject = read.statement.subject[0];
154
+ const digest = subject?.digest.sha256 ?? "";
155
+ // The patch bytes exactly as they are in the file. A verifier checking a patch it
156
+ // normalised first (line endings, trailing newline) would report a false mismatch
157
+ // for an honest file, so the comparison is over the raw bytes and says so.
158
+ const actual = sha256Hex(patch);
159
+ if (actual !== digest) {
160
+ return {
161
+ ok: false,
162
+ check: "subject_digest",
163
+ message: `The patch file hashes to ${actual.slice(0, 16)}…, but the record names ${digest.slice(0, 16)}…. This is not the patch that was approved, byte for byte.`,
164
+ };
165
+ }
166
+ }
167
+ /**
168
+ * Every keyid whose signature verified, so a caller can ask about each of them.
169
+ *
170
+ * A retirement is per-key, and a counter-signed record has more than one signer. Asking
171
+ * only about the first would let a record whose *second* signature came from a disowned
172
+ * key report clean.
173
+ */
174
+ const retirement = judgeEverySigner(inputs.retirements ?? null, verification.satisfied, read.statement);
175
+ if (!retirement.ok) {
176
+ return retirement;
177
+ }
178
+ /*
179
+ * The keyid reported is the signer whose *record* signature this is, meaning the first
180
+ * key the verifier holds that signed. With one key that is that key; with two it is
181
+ * whichever came first in the supplied list, which is why the outcome also carries
182
+ * `signers` — a caller reporting "verified by X" needs the whole list, not a guess.
183
+ */
184
+ return {
185
+ ok: true,
186
+ statement: read.statement,
187
+ keyid: verification.satisfied[0] ?? keys[0].keyid,
188
+ signers: verification.satisfied,
189
+ retirements: retirement.verdicts,
190
+ };
191
+ }
192
+ /**
193
+ * Asks the retirement list about each verified signer.
194
+ *
195
+ * Returns the refusal when a signature was made *after* its key was retired, and the
196
+ * verdicts otherwise. `retired_after_signing` is reported, not refused: the record is
197
+ * genuinely signed and was genuine when it was made, so failing it would retroactively
198
+ * convict every honest approval a machine ever produced. A verifier who needs to refuse it
199
+ * asks for that with `requireLiveKeyids`.
200
+ */
201
+ function judgeEverySigner(list, signers, statement) {
202
+ const completedAt = statement.predicate.completed_at ?? null;
203
+ if (list === null) {
204
+ // No list supplied. Every signer is unchecked, which is *not* "not listed": the caller
205
+ // must be able to tell "I checked and found nothing" from "I was given nothing to
206
+ // check against". Both are reported as `not_listed` only when a list was supplied.
207
+ return { ok: true, verdicts: signers.map((keyid) => ({ keyid, state: "unchecked" })) };
208
+ }
209
+ const verdicts = [];
210
+ for (const keyid of signers) {
211
+ const verdict = judgeRetirement(list, keyid, { completedAt });
212
+ if (verdict.state === "retired_before_signing") {
213
+ return {
214
+ ok: false,
215
+ check: "key_retired_before_signing",
216
+ message: `This record was sealed at ${completedAt ?? "(no completed_at)"}, but key ${keyid.slice(0, 16)}… was retired at ${verdict.retirement.retired_at} — ${verdict.retirement.reason}. A signature by a key its own operator had already disowned is not evidence that they approved anything.`,
217
+ };
218
+ }
219
+ verdicts.push({ keyid, ...verdict });
220
+ }
221
+ return { ok: true, verdicts };
222
+ }
223
+ function sha256Hex(bytes) {
224
+ return createHash("sha256").update(bytes).digest("hex");
225
+ }
226
+ export function readAttestationFile(path) {
227
+ let text;
228
+ try {
229
+ text = readFileSync(path, "utf8");
230
+ }
231
+ catch (cause) {
232
+ // A missing path, a directory, a permissions problem. Distinct from "not JSON",
233
+ // because "I cannot read that file" and "that file is not a record" send the
234
+ // operator to different places.
235
+ const code = cause.code ?? "unknown";
236
+ return {
237
+ ok: false,
238
+ check: "file_unreadable",
239
+ message: `Cannot read ${path} (${code}).`,
240
+ };
241
+ }
242
+ if (text.trim().length === 0) {
243
+ return { ok: false, check: "file_json", message: `${path} is empty, so it holds no record.` };
244
+ }
245
+ try {
246
+ return { ok: true, file: JSON.parse(text) };
247
+ }
248
+ catch {
249
+ // The parser's own message is deliberately not passed on: it quotes the offending
250
+ // text, which for a large file is a wall of noise, and it says nothing the operator
251
+ // can act on that "this is not JSON" does not.
252
+ return {
253
+ ok: false,
254
+ check: "file_json",
255
+ message: `${path} is not JSON. A signed record is the .json file downloaded from Imbas.`,
256
+ };
257
+ }
258
+ }
259
+ /** Re-exported so a caller needs one import for the whole flow. */
260
+ export { APPROVAL_PREDICATE_TYPE, DSSE_PAYLOAD_TYPE, IN_TOTO_STATEMENT_TYPE, };
package/describe.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * How the verifier describes a retirement list's own signature.
3
+ *
4
+ * Split out so `verify.ts` can print this without importing `attest.ts`.
5
+ */
6
+ import type { ListSignatureState } from "./retirement.js";
7
+ /** One sentence per list-signature state, in the words the docs use. */
8
+ export declare function describeListSignature(state: ListSignatureState): string;
package/describe.js ADDED
@@ -0,0 +1,15 @@
1
+ /** One sentence per list-signature state, in the words the docs use. */
2
+ export function describeListSignature(state) {
3
+ switch (state.state) {
4
+ case "signed":
5
+ return `signed by policy key ${state.keyid.slice(0, 16)}...`;
6
+ case "unsigned":
7
+ return "UNSIGNED - this list carries no signature, so nothing proves the policy key wrote it";
8
+ case "unverified_signature":
9
+ return "signed, but no policy key was supplied to check it - the signature was not verified";
10
+ case "unknown_signer":
11
+ return `signed by ${state.keyid.slice(0, 16)}..., which is not the policy key you supplied`;
12
+ case "signature_invalid":
13
+ return `SIGNATURE INVALID - ${state.message}`;
14
+ }
15
+ }
package/dsse.d.ts ADDED
@@ -0,0 +1,146 @@
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
+ /** What a DSSE envelope's payload is. in-toto statements are JSON. */
18
+ export declare const DSSE_PAYLOAD_TYPE = "application/vnd.in-toto+json";
19
+ /** The in-toto Statement type. */
20
+ export declare const IN_TOTO_STATEMENT_TYPE = "https://in-toto.io/Statement/v1";
21
+ /** Imbas's own predicate type: an approval. Nothing else claims this URI. */
22
+ export declare const APPROVAL_PREDICATE_TYPE = "https://somernorth.co.uk/imbas/attestation/approval/v1";
23
+ export interface DsseSignature {
24
+ /** The keyid of the signing key: sha256 of the DER SPKI, hex. */
25
+ keyid: string;
26
+ /** ECDSA P-256 / SHA-256 signature over the PAE bytes, DER-encoded, base64. */
27
+ sig: string;
28
+ }
29
+ export interface DsseEnvelope {
30
+ payloadType: string;
31
+ /** The statement, base64. */
32
+ payload: string;
33
+ signatures: DsseSignature[];
34
+ }
35
+ /**
36
+ * Pre-Authentication Encoding, exactly as the DSSE spec defines it:
37
+ *
38
+ * PAE(type, body) = "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body
39
+ *
40
+ * where SP is one space and LEN is the length in *bytes*, written in ASCII decimal.
41
+ *
42
+ * The length prefixes are not decoration. Without them, ("ab", "c") and ("a", "bc")
43
+ * encode to the same string, so a signature that verified one would verify the other.
44
+ * Lengths in bytes rather than characters for the same reason one level down: a
45
+ * payload with non-ASCII text has more bytes than characters, and counting characters
46
+ * would both short-count and split a UTF-8 sequence.
47
+ *
48
+ * The spec's own test vector is asserted in the tests:
49
+ * PAE("http://example.com/HelloWorld", "hello world")
50
+ * = "DSSEv1 29 http://example.com/HelloWorld 11 hello world"
51
+ */
52
+ export declare function pae(payloadType: string, payload: Buffer): Buffer;
53
+ /**
54
+ * Strict base64 decoding.
55
+ *
56
+ * `Buffer.from(text, "base64")` is lenient: it skips characters it does not
57
+ * recognise and stops at the first invalid one. That is fine for reading a file and
58
+ * wrong for verifying a signature, because a tampered payload that decodes "well
59
+ * enough" would be checked against a signature over different bytes and the failure
60
+ * would look like bad crypto rather than bad encoding. This rejects it outright.
61
+ */
62
+ export declare function decodeBase64(text: string): Buffer | null;
63
+ /**
64
+ * Signs a payload, producing a one-signature envelope.
65
+ *
66
+ * `payloadType` defaults to the in-toto statement type because almost every envelope here
67
+ * carries one. The retirement list passes its own: the type is part of the PAE bytes the
68
+ * signature covers, so it must be chosen *before* signing. Relabelling an envelope
69
+ * afterwards would leave a signature over one document beside a file claiming to be
70
+ * another, and nothing would verify it.
71
+ */
72
+ export declare function signEnvelope(payload: Buffer, key: InstallationKey, payloadType?: string): DsseEnvelope;
73
+ /**
74
+ * Adds a second, independent signature to an envelope that is already signed.
75
+ *
76
+ * This is what makes one installation's word checkable by another. The payload is not
77
+ * touched — not re-serialised, not reformatted — because the whole value of the first
78
+ * signature is that it covers these exact bytes. A counter-signature over a
79
+ * re-serialised copy would be a signature over a different document that happened to
80
+ * mean the same thing, which is not the same claim.
81
+ *
82
+ * The payload arrives as bytes rather than as the envelope's base64 for one reason: the
83
+ * caller must have already verified the existing signature and decoded it. Adding a
84
+ * signature to bytes nobody has checked would let a counter-signer endorse a forged
85
+ * record, and the parameter shape makes that obligation visible at the call site rather
86
+ * than leaving it to a comment. See `verifyEnvelopeSignatures`, which is how a caller
87
+ * earns the right to call this.
88
+ *
89
+ * Refuses a key that already signed. Two signatures with one keyid make "signed by whom"
90
+ * ambiguous, and an ambiguous answer is worse than a refused request: a reader would
91
+ * count two signatures and conclude two parties.
92
+ */
93
+ export declare function counterSignEnvelope(envelope: DsseEnvelope, payload: Buffer, key: InstallationKey): DsseEnvelope;
94
+ /**
95
+ * Which check failed, so a verifier can say the first one rather than "invalid".
96
+ *
97
+ * An operator looking at a failed verification needs to know whether the record was
98
+ * altered, whether they were handed the wrong key, or whether they asked the wrong
99
+ * question. "Signature invalid" collapses all three.
100
+ */
101
+ export type EnvelopeCheck = "payload_type" | "payload_encoding" | "signature_present" | "keyid" | "signature";
102
+ /** One signature's fate, for the per-signature report a reader needs. */
103
+ export interface SignatureCheck {
104
+ keyid: string;
105
+ ok: boolean;
106
+ check: EnvelopeCheck | null;
107
+ message: string | null;
108
+ }
109
+ export type MultiSignatureVerification = {
110
+ ok: true;
111
+ payload: Buffer;
112
+ signatures: SignatureCheck[];
113
+ satisfied: string[];
114
+ } | {
115
+ ok: false;
116
+ payload: Buffer | null;
117
+ signatures: SignatureCheck[];
118
+ satisfied: string[];
119
+ check: EnvelopeCheck | "keyid_missing" | "counter_signature_required";
120
+ message: string;
121
+ };
122
+ /**
123
+ * Verifies every signature on an envelope against the keys supplied.
124
+ *
125
+ * The property that matters: **each signature must verify over the same payload.** DSSE
126
+ * signatures cover PAE(payloadType, payload), so an attacker cannot move a signature from
127
+ * one document to another — but a *reader* can still be misled by counting. This function
128
+ * therefore reports per-signature results rather than a single boolean, so "two
129
+ * signatures, one of which does not verify" is visible instead of rounding to "signed by
130
+ * two parties".
131
+ *
132
+ * `requireKeyids` is the counter-signature check. A reader who needs a second party to
133
+ * have seen the record passes the keyids they require, and a record missing one fails at
134
+ * `counter_signature_required` rather than passing because *a* key signed it.
135
+ *
136
+ * An unknown keyid is `keyid_missing`, not `signature`: "I have no key for this signer"
137
+ * and "this signature is wrong" are different findings, and a verifier holding one of two
138
+ * keys must not be told the record is forged.
139
+ */
140
+ export declare function verifyEnvelopeSignatures(envelope: DsseEnvelope, keys: readonly {
141
+ publicPem: string;
142
+ keyid: string;
143
+ }[], options?: {
144
+ requireKeyids?: readonly string[];
145
+ expectedPayloadType?: string;
146
+ }): MultiSignatureVerification;