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/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;
|