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