@push.rocks/smartlinuxrepo 1.0.0 → 1.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.
@@ -1,4 +1,5 @@
1
- import { LinuxRepoError } from '../shared/errors.js';
1
+ import { decodeLatin1, decodeStrictUtf8 } from '../readers/text.js';
2
+ import { LinuxRepoError, type TLinuxRepoErrorCode } from '../shared/errors.js';
2
3
  import { assertAsyncIterable, assertBytes, assertString, assertTimestamp } from '../shared/validation.js';
3
4
  import { singleChunk, type IOpenPgpSigner } from './signers.js';
4
5
 
@@ -6,6 +7,10 @@ import { singleChunk, type IOpenPgpSigner } from './signers.js';
6
7
  * The OpenPGP framing the codec owns: only the bytes of the signature packet
7
8
  * may vary; framing and armor are deterministic.
8
9
  * Armor has no headers, 64-column base64 and a CRC-24 line.
10
+ *
11
+ * The readers below are the strict inverses the verifier uses; openpgp.js's
12
+ * own dearmoring is never used (it skips text around the block, ignores the
13
+ * CRC-24 and strips trailing whitespace inside cleartext).
9
14
  */
10
15
 
11
16
  const BASE64_LINE = 64;
@@ -47,7 +52,7 @@ export async function createDetachedArmoredSignature(inputArg: {
47
52
  * Cleartext-signs `text` (`InRelease`). The text must be canonical: no CR and
48
53
  * no trailing spaces or tabs on any line. The signed text is `text` without
49
54
  * its final line break; the output always ends the text with one line break
50
- * before the signature block, as GnuPG and Go do. Lines starting with `-` are
55
+ * before the signature block, as GnuPG does. Lines starting with `-` are
51
56
  * dash-escaped. The header is always `Hash: SHA256`.
52
57
  */
53
58
  export async function createCleartextSignedMessage(inputArg: {
@@ -98,6 +103,294 @@ function assertSignatureBytes(packetArg: unknown): asserts packetArg is Uint8Arr
98
103
  }
99
104
  }
100
105
 
106
+ /** How a framing reader refuses its input: the error code and the input field it names. */
107
+ export interface IFramingFailure {
108
+ code: TLinuxRepoErrorCode;
109
+ field: string;
110
+ }
111
+
112
+ export type TArmorLabel = 'PUBLIC KEY BLOCK' | 'SIGNATURE';
113
+
114
+ /** A well-formed armor header line (`Key: Value`), as openpgp.js 6.3.2 checks it (`verifyHeaders$1`). */
115
+ const ARMOR_HEADER_PATTERN = /^([^\s:]|[^\s:][^:]*[^\s:]): .+$/;
116
+ const BASE64_LINE_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
117
+ const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/;
118
+ const CRC_LINE_PATTERN = /^=[A-Za-z0-9+/]{4}$/;
119
+ const CLEARTEXT_BEGIN = '-----BEGIN PGP SIGNED MESSAGE-----';
120
+ const SIGNATURE_BEGIN = '-----BEGIN PGP SIGNATURE-----';
121
+
122
+ /** RFC 9580 table 23 hash text names (the `Hash:` header) and their algorithm IDs. */
123
+ const HASH_TEXT_NAMES: ReadonlyMap<string, number> = new Map([
124
+ ['MD5', 1],
125
+ ['SHA1', 2],
126
+ ['RIPEMD160', 3],
127
+ ['SHA256', 8],
128
+ ['SHA384', 9],
129
+ ['SHA512', 10],
130
+ ['SHA224', 11],
131
+ ['SHA3-256', 12],
132
+ ['SHA3-512', 14],
133
+ ]);
134
+
135
+ function framingError(failureArg: IFramingFailure, messageArg: string): LinuxRepoError {
136
+ return new LinuxRepoError(failureArg.code, messageArg, { field: failureArg.field });
137
+ }
138
+
139
+ /** Bytes that must be ASCII armor, as text; any byte above 0x7F is refused. */
140
+ export function decodeArmorBytes(bytesArg: Uint8Array, failureArg: IFramingFailure): string {
141
+ for (let i = 0; i < bytesArg.length; i++) {
142
+ if (bytesArg[i] > 0x7f) {
143
+ throw framingError(failureArg, 'ASCII armor must be ASCII');
144
+ }
145
+ }
146
+ return decodeLatin1(bytesArg);
147
+ }
148
+
149
+ /**
150
+ * Reads exactly one ASCII armor block labelled `labelArg` (RFC 9580 section
151
+ * 6.2) and returns its bytes; the inverse of `armorSignature`. Lines may end
152
+ * in LF or CRLF and carry trailing spaces or tabs, and blank lines may
153
+ * surround the block. Well-formed `Key: Value` headers are ignored; the
154
+ * CRC-24 line is optional and must match when present. Refused with
155
+ * `failureArg`: non-ASCII text, another label, a second block or any other
156
+ * text outside the block, a malformed header line, no blank line before the
157
+ * data, base64 that is not canonical, no data, and a CRC-24 that does not
158
+ * match.
159
+ */
160
+ export function dearmor(textArg: string, labelArg: TArmorLabel, failureArg: IFramingFailure): Uint8Array {
161
+ const lines = textArg.split('\n').map(trimLineEnd);
162
+ let first = 0;
163
+ while (first < lines.length && lines[first] === '') {
164
+ first += 1;
165
+ }
166
+ let last = lines.length - 1;
167
+ while (last >= first && lines[last] === '') {
168
+ last -= 1;
169
+ }
170
+ return readArmorBlock(lines.slice(first, last + 1), labelArg, failureArg);
171
+ }
172
+
173
+ /** The line without trailing spaces, tabs and CRs; a loop, as a `+$` regex is quadratic on a long run of them. */
174
+ function trimLineEnd(lineArg: string): string {
175
+ let end = lineArg.length;
176
+ while (end > 0 && (lineArg[end - 1] === ' ' || lineArg[end - 1] === '\t' || lineArg[end - 1] === '\r')) {
177
+ end -= 1;
178
+ }
179
+ return lineArg.slice(0, end);
180
+ }
181
+
182
+ /** One armor block, from its BEGIN line to its END line, as exactly these lines; ASCII only. */
183
+ function readArmorBlock(linesArg: readonly string[], labelArg: TArmorLabel, failureArg: IFramingFailure): Uint8Array {
184
+ if (linesArg.some((line) => !/^[\x00-\x7f]*$/.test(line))) {
185
+ throw framingError(failureArg, 'ASCII armor must be ASCII');
186
+ }
187
+ const begin = `-----BEGIN PGP ${labelArg}-----`;
188
+ const end = `-----END PGP ${labelArg}-----`;
189
+ if (linesArg[0] !== begin) {
190
+ throw framingError(failureArg, `expected exactly one ${begin} block and nothing else`);
191
+ }
192
+ const endIndex = linesArg.indexOf(end);
193
+ if (endIndex < 0 || endIndex !== linesArg.length - 1) {
194
+ throw framingError(failureArg, `expected exactly one ${begin} block ending in ${end} and nothing else`);
195
+ }
196
+ let index = 1;
197
+ for (; index < endIndex && linesArg[index] !== ''; index++) {
198
+ if (!ARMOR_HEADER_PATTERN.test(linesArg[index])) {
199
+ throw framingError(failureArg, 'malformed armor header line');
200
+ }
201
+ }
202
+ if (index >= endIndex) {
203
+ throw framingError(failureArg, 'the armor has no blank line before its data');
204
+ }
205
+ index += 1;
206
+ const dataLines: string[] = [];
207
+ let crcLine: string | null = null;
208
+ for (; index < endIndex; index++) {
209
+ const line = linesArg[index];
210
+ if (index === endIndex - 1 && line.startsWith('=')) {
211
+ crcLine = line;
212
+ break;
213
+ }
214
+ if (!BASE64_LINE_PATTERN.test(line)) {
215
+ throw framingError(failureArg, 'the armor data is not base64');
216
+ }
217
+ dataLines.push(line);
218
+ }
219
+ const base64 = dataLines.join('');
220
+ if (base64.length === 0 || base64.length % 4 !== 0 || !BASE64_PATTERN.test(base64)) {
221
+ throw framingError(failureArg, 'the armor data is not one base64 string');
222
+ }
223
+ const decoded = Buffer.from(base64, 'base64');
224
+ if (decoded.toString('base64') !== base64) {
225
+ throw framingError(failureArg, 'the armor data is not canonical base64');
226
+ }
227
+ const bytes = new Uint8Array(decoded.buffer, decoded.byteOffset, decoded.byteLength);
228
+ if (crcLine !== null && (!CRC_LINE_PATTERN.test(crcLine) || crcLine.slice(1) !== crc24Base64(bytes))) {
229
+ throw framingError(failureArg, 'the armor CRC-24 does not match its data');
230
+ }
231
+ return bytes;
232
+ }
233
+
234
+ /**
235
+ * The packet tags of a binary OpenPGP packet sequence, read from the packet
236
+ * headers alone (RFC 9580 section 4.2); no body is parsed. openpgp.js skips
237
+ * marker, trust and padding packets and unknown non-critical packet types
238
+ * without a trace (`PacketList.read`, `dist/node/openpgp.mjs:12320-12385`),
239
+ * so the verifier counts packets here. Refused with `failureArg`: a header
240
+ * without its high bit, a partial body length (no key or signature packet
241
+ * may use one), and input that ends inside a header or a body.
242
+ */
243
+ export function readPacketTags(bytesArg: Uint8Array, failureArg: IFramingFailure): number[] {
244
+ const tags: number[] = [];
245
+ let offset = 0;
246
+ while (offset < bytesArg.length) {
247
+ const ctb = bytesArg[offset];
248
+ if ((ctb & 0x80) === 0) {
249
+ throw framingError(failureArg, `no OpenPGP packet header at byte ${offset}`);
250
+ }
251
+ let tag: number;
252
+ let headerLength: number;
253
+ let bodyLength: number;
254
+ if ((ctb & 0x40) !== 0) {
255
+ tag = ctb & 0x3f;
256
+ const first = bytesArg[offset + 1];
257
+ if (first === undefined) {
258
+ throw framingError(failureArg, 'the input ends inside a packet header');
259
+ }
260
+ if (first < 192) {
261
+ headerLength = 2;
262
+ bodyLength = first;
263
+ } else if (first < 224) {
264
+ if (offset + 3 > bytesArg.length) {
265
+ throw framingError(failureArg, 'the input ends inside a packet header');
266
+ }
267
+ headerLength = 3;
268
+ bodyLength = (first - 192) * 256 + bytesArg[offset + 2] + 192;
269
+ } else if (first === 255) {
270
+ if (offset + 6 > bytesArg.length) {
271
+ throw framingError(failureArg, 'the input ends inside a packet header');
272
+ }
273
+ headerLength = 6;
274
+ bodyLength = readUint32(bytesArg, offset + 2);
275
+ } else {
276
+ throw framingError(failureArg, 'a partial body length is not allowed here');
277
+ }
278
+ } else {
279
+ tag = (ctb >> 2) & 0x0f;
280
+ const lengthType = ctb & 0x03;
281
+ if (lengthType === 3) {
282
+ // indeterminate length: the packet runs to the end of the input
283
+ headerLength = 1;
284
+ bodyLength = bytesArg.length - offset - 1;
285
+ } else {
286
+ const lengthBytes = 1 << lengthType;
287
+ if (offset + 1 + lengthBytes > bytesArg.length) {
288
+ throw framingError(failureArg, 'the input ends inside a packet header');
289
+ }
290
+ headerLength = 1 + lengthBytes;
291
+ bodyLength = 0;
292
+ for (let i = 0; i < lengthBytes; i++) {
293
+ bodyLength = bodyLength * 256 + bytesArg[offset + 1 + i];
294
+ }
295
+ }
296
+ }
297
+ const packetEnd = offset + headerLength + bodyLength;
298
+ if (packetEnd > bytesArg.length) {
299
+ throw framingError(failureArg, 'the input ends inside a packet');
300
+ }
301
+ tags.push(tag);
302
+ offset = packetEnd;
303
+ }
304
+ return tags;
305
+ }
306
+
307
+ function readUint32(bytesArg: Uint8Array, offsetArg: number): number {
308
+ return ((bytesArg[offsetArg] * 256 + bytesArg[offsetArg + 1]) * 256 + bytesArg[offsetArg + 2]) * 256 + bytesArg[offsetArg + 3];
309
+ }
310
+
311
+ /** A cleartext-signed message split into its parts. */
312
+ export interface ICleartextSignedParts {
313
+ /** Dash-escaping removed, lines joined by LF, without the line break before the signature block. */
314
+ signedText: string;
315
+ /** The RFC 9580 hash algorithm IDs the `Hash:` headers name, in order; empty without headers. */
316
+ headerHashAlgorithms: number[];
317
+ /** The bytes of the signature armor block. */
318
+ signature: Uint8Array;
319
+ }
320
+
321
+ /**
322
+ * Splits a cleartext-signed message (`InRelease`, RFC 9580 section 7); the
323
+ * inverse of `createCleartextSignedMessage`, and as strict as apt's own split
324
+ * (apt-pkg `SplitClearSignedFile`). The message is strict UTF-8 with LF line
325
+ * endings: the first line is `-----BEGIN PGP SIGNED MESSAGE-----`, then
326
+ * optional `Hash:` headers (comma-separated RFC 9580 text names), a blank
327
+ * line, the dash-escaped text and exactly one signature armor block, which
328
+ * may be followed by one line break and nothing else. Refused with
329
+ * `failureArg`: invalid UTF-8, a CR, a line ending in a space or tab, text
330
+ * before the message block or after the signature block, any other header, an
331
+ * unknown hash name, a text line starting with `-` but not `- `, and an empty
332
+ * text section. Armor headers inside the signature block are ignored as in
333
+ * `dearmor`.
334
+ */
335
+ export function splitCleartextSignedMessage(bytesArg: Uint8Array, failureArg: IFramingFailure): ICleartextSignedParts {
336
+ const text = decodeStrictUtf8(bytesArg);
337
+ if (text === null) {
338
+ throw framingError(failureArg, 'the cleartext-signed message is not valid UTF-8');
339
+ }
340
+ if (text.includes('\r')) {
341
+ throw framingError(failureArg, 'the cleartext-signed message must use LF line endings');
342
+ }
343
+ const lines = text.split('\n');
344
+ if (lines[lines.length - 1] === '') {
345
+ lines.pop();
346
+ }
347
+ if (lines.some((line) => /[ \t]$/.test(line))) {
348
+ throw framingError(failureArg, 'a line of the cleartext-signed message ends with a space or tab');
349
+ }
350
+ if (lines[0] !== CLEARTEXT_BEGIN) {
351
+ throw framingError(failureArg, `the message does not start with ${CLEARTEXT_BEGIN}`);
352
+ }
353
+ let index = 1;
354
+ const headerHashAlgorithms: number[] = [];
355
+ for (; index < lines.length && lines[index] !== ''; index++) {
356
+ const header = /^Hash: (.+)$/.exec(lines[index]);
357
+ if (header === null) {
358
+ throw framingError(failureArg, 'only Hash: headers are allowed in a cleartext-signed message');
359
+ }
360
+ for (const name of header[1].split(',')) {
361
+ const algorithm = HASH_TEXT_NAMES.get(name.trim());
362
+ if (algorithm === undefined) {
363
+ throw framingError(failureArg, `unknown hash name in the Hash: header: ${name.trim()}`);
364
+ }
365
+ headerHashAlgorithms.push(algorithm);
366
+ }
367
+ }
368
+ index += 1;
369
+ const textLines: string[] = [];
370
+ for (; index < lines.length && lines[index] !== SIGNATURE_BEGIN; index++) {
371
+ const line = lines[index];
372
+ if (line.startsWith('-')) {
373
+ if (!line.startsWith('- ')) {
374
+ throw framingError(failureArg, 'a text line starts with "-" without the "- " escape');
375
+ }
376
+ textLines.push(line.slice(2));
377
+ } else {
378
+ textLines.push(line);
379
+ }
380
+ }
381
+ if (index >= lines.length) {
382
+ throw framingError(failureArg, `the message has no ${SIGNATURE_BEGIN} block`);
383
+ }
384
+ if (textLines.length === 0) {
385
+ throw framingError(failureArg, 'the message has no text section');
386
+ }
387
+ return {
388
+ signedText: textLines.join('\n'),
389
+ headerHashAlgorithms,
390
+ signature: readArmorBlock(lines.slice(index), 'SIGNATURE', failureArg),
391
+ };
392
+ }
393
+
101
394
  /** RFC 4880 section 6.1 CRC-24, base64-encoded. */
102
395
  function crc24Base64(bytesArg: Uint8Array): string {
103
396
  let crc = 0xb704ce;
@@ -0,0 +1,357 @@
1
+ import * as plugins from '../plugins.js';
2
+ import { LinuxRepoError } from '../shared/errors.js';
3
+ import { assertBytes, assertString, assertTimestamp } from '../shared/validation.js';
4
+ import {
5
+ dearmor,
6
+ decodeArmorBytes,
7
+ readPacketTags,
8
+ splitCleartextSignedMessage,
9
+ type IFramingFailure,
10
+ } from './openpgpframing.js';
11
+
12
+ /** The hash algorithms the verifier accepts. */
13
+ export type TOpenPgpHashAlgorithm = 'sha256' | 'sha384' | 'sha512';
14
+
15
+ /** A signature that verified. */
16
+ export interface IOpenPgpSignatureVerification {
17
+ /** The primary key or subkey that verified the signature, 40 uppercase hex digits. */
18
+ readonly signingKeyFingerprint: string;
19
+ /** The signature's creation time (whole seconds). */
20
+ readonly created: Date;
21
+ readonly hashAlgorithm: TOpenPgpHashAlgorithm;
22
+ }
23
+
24
+ /** A cleartext-signed message that verified. */
25
+ export interface IOpenPgpCleartextVerification extends IOpenPgpSignatureVerification {
26
+ /**
27
+ * The signed text: dash-escaping removed, lines joined by LF, without the
28
+ * line break before the signature block (RFC 9580 section 7.1). For
29
+ * GnuPG's and the codec's `InRelease` this is `Release` without its final
30
+ * line break; for Gitea 28.2.0's (go-crypto) it is the whole `Release`.
31
+ */
32
+ readonly signedText: string;
33
+ }
34
+
35
+ /**
36
+ * Verifies OpenPGP signatures against one public key. Every method fails
37
+ * closed with a `LinuxRepoError` and never returns a verdict of failure:
38
+ * `malformed-signature`, `wrong-key`, `unusable-key` or `bad-signature`
39
+ * (bad arguments are `invalid-input`).
40
+ *
41
+ * A signature is valid when it is exactly one OpenPGP v4 signature packet,
42
+ * SHA-256, SHA-384 or SHA-512, issued by the primary key or one of its
43
+ * subkeys, created at or before `date` and not expired at `date`, made by a
44
+ * key or subkey that can sign both at the signature's creation time and at
45
+ * `date` (not expired, not revoked, sign flag set, valid binding and
46
+ * back-signature, openpgp.js's algorithm floor), and when it verifies
47
+ * cryptographically. `date` is required; the codec reads no clock.
48
+ */
49
+ export interface IOpenPgpVerifier {
50
+ /** The primary key's v4 fingerprint, 40 uppercase hex digits (as `IOpenPgpKeyPair.fingerprint`). */
51
+ readonly fingerprint: string;
52
+ /**
53
+ * A binary detached signature of type 0x00 over `data`: the packet
54
+ * `IOpenPgpSigner.signBinary` returns, an Arch `.sig`.
55
+ */
56
+ verifyDetachedSignature(inputArg: { data: Uint8Array; signature: Uint8Array; date: Date }): Promise<IOpenPgpSignatureVerification>;
57
+ /**
58
+ * An armored detached signature of type 0x00 over `data`: `Release.gpg`,
59
+ * `repomd.xml.asc`. The armor is ASCII.
60
+ */
61
+ verifyArmoredDetachedSignature(inputArg: {
62
+ data: Uint8Array;
63
+ signatureArmored: Uint8Array;
64
+ date: Date;
65
+ }): Promise<IOpenPgpSignatureVerification>;
66
+ /**
67
+ * A cleartext-signed message with a signature of type 0x01: `InRelease`.
68
+ * The message is strict UTF-8 with LF line endings; see
69
+ * `splitCleartextSignedMessage` for what its framing may hold.
70
+ */
71
+ verifyCleartextMessage(inputArg: { message: Uint8Array; date: Date }): Promise<IOpenPgpCleartextVerification>;
72
+ }
73
+
74
+ const HASH_ALGORITHMS: ReadonlyMap<number, TOpenPgpHashAlgorithm> = new Map([
75
+ [plugins.openpgp.enums.hash.sha256, 'sha256'],
76
+ [plugins.openpgp.enums.hash.sha384, 'sha384'],
77
+ [plugins.openpgp.enums.hash.sha512, 'sha512'],
78
+ ]);
79
+
80
+ /** Subpacket types openpgp.js reads in the unhashed area (issuer, embedded signature, issuer fingerprint). */
81
+ const UNHASHED_SUBPACKETS_READ = new Set([16, 32, 33]);
82
+
83
+ /**
84
+ * The openpgp.js settings verification depends on, pinned instead of taken
85
+ * from the process-wide `openpgp.config`. The values are openpgp.js 6.3.2's
86
+ * defaults except `ignoreUnsupportedPackets`.
87
+ */
88
+ const VERIFY_CONFIG: plugins.openpgp.Config = {
89
+ ...plugins.openpgp.config,
90
+ ignoreUnsupportedPackets: false,
91
+ ignoreMalformedPackets: false,
92
+ enableParsingV5Entities: false,
93
+ additionalAllowedPackets: [],
94
+ allowMissingKeyFlags: false,
95
+ allowInsecureVerificationWithReformattedKeys: false,
96
+ minRSABits: 2047,
97
+ knownNotations: [],
98
+ rejectHashAlgorithms: new Set([plugins.openpgp.enums.hash.md5, plugins.openpgp.enums.hash.ripemd]),
99
+ rejectMessageHashAlgorithms: new Set([
100
+ plugins.openpgp.enums.hash.md5,
101
+ plugins.openpgp.enums.hash.ripemd,
102
+ plugins.openpgp.enums.hash.sha1,
103
+ ]),
104
+ rejectPublicKeyAlgorithms: new Set([plugins.openpgp.enums.publicKey.elgamal, plugins.openpgp.enums.publicKey.dsa]),
105
+ rejectCurves: new Set([plugins.openpgp.enums.curve.secp256k1]),
106
+ };
107
+
108
+ /**
109
+ * Builds a verifier from one armored OpenPGP public key: exactly one
110
+ * transferable v4 public key (the primary key with its user IDs, subkeys and
111
+ * self-signatures, all v4) in exactly one `PUBLIC KEY BLOCK`. A private key
112
+ * block, several blocks or keys, a v6 key, text around the block, an armor
113
+ * CRC-24 that does not match or any other garbage is `invalid-key`
114
+ * (`detail.field` `publicKeyArmored`). Only the structure is checked here;
115
+ * whether a key can sign depends on time and is checked at verification.
116
+ */
117
+ export async function createOpenPgpVerifier(inputArg: { publicKeyArmored: string }): Promise<IOpenPgpVerifier> {
118
+ if (inputArg === null || typeof inputArg !== 'object') {
119
+ throw new LinuxRepoError('invalid-input', 'input must be an object', { field: 'input' });
120
+ }
121
+ assertString(inputArg.publicKeyArmored, 'publicKeyArmored');
122
+ const keyBytes = dearmor(inputArg.publicKeyArmored, 'PUBLIC KEY BLOCK', { code: 'invalid-key', field: 'publicKeyArmored' });
123
+ let keys: plugins.openpgp.Key[];
124
+ try {
125
+ keys = await plugins.openpgp.readKeys({ binaryKeys: keyBytes, config: VERIFY_CONFIG });
126
+ } catch (error) {
127
+ throw new LinuxRepoError('invalid-key', 'the OpenPGP public key cannot be read', { field: 'publicKeyArmored' }, {
128
+ cause: error,
129
+ });
130
+ }
131
+ if (keys.length !== 1) {
132
+ throw new LinuxRepoError('invalid-key', `expected exactly one OpenPGP public key, got ${keys.length}`, {
133
+ field: 'publicKeyArmored',
134
+ });
135
+ }
136
+ const key = keys[0];
137
+ if (key.isPrivate()) {
138
+ throw new LinuxRepoError('invalid-key', 'a private key is not accepted as the verification key', {
139
+ field: 'publicKeyArmored',
140
+ });
141
+ }
142
+ if (key.keyPacket.version !== 4 || key.subkeys.some((subkey) => subkey.keyPacket.version !== 4)) {
143
+ throw new LinuxRepoError('invalid-key', 'only OpenPGP v4 keys and subkeys are accepted', { field: 'publicKeyArmored' });
144
+ }
145
+ const publicKey: plugins.openpgp.PublicKey = key;
146
+ return {
147
+ fingerprint: publicKey.getFingerprint().toUpperCase(),
148
+ verifyDetachedSignature: async (input) => {
149
+ assertInputObject(input);
150
+ assertBytes(input.data, 'data');
151
+ assertBytes(input.signature, 'signature');
152
+ assertTimestamp(input.date, 'date');
153
+ return verifySignature({
154
+ key: publicKey,
155
+ packets: input.signature,
156
+ content: { binary: input.data },
157
+ signatureType: plugins.openpgp.enums.signature.binary,
158
+ date: input.date,
159
+ field: 'signature',
160
+ headerHashAlgorithms: [],
161
+ });
162
+ },
163
+ verifyArmoredDetachedSignature: async (input) => {
164
+ assertInputObject(input);
165
+ assertBytes(input.data, 'data');
166
+ assertBytes(input.signatureArmored, 'signatureArmored');
167
+ assertTimestamp(input.date, 'date');
168
+ const failure: IFramingFailure = { code: 'malformed-signature', field: 'signatureArmored' };
169
+ return verifySignature({
170
+ key: publicKey,
171
+ packets: dearmor(decodeArmorBytes(input.signatureArmored, failure), 'SIGNATURE', failure),
172
+ content: { binary: input.data },
173
+ signatureType: plugins.openpgp.enums.signature.binary,
174
+ date: input.date,
175
+ field: 'signatureArmored',
176
+ headerHashAlgorithms: [],
177
+ });
178
+ },
179
+ verifyCleartextMessage: async (input) => {
180
+ assertInputObject(input);
181
+ assertBytes(input.message, 'message');
182
+ assertTimestamp(input.date, 'date');
183
+ const parts = splitCleartextSignedMessage(input.message, { code: 'malformed-signature', field: 'message' });
184
+ const verification = await verifySignature({
185
+ key: publicKey,
186
+ packets: parts.signature,
187
+ // a text signature hashes the text with CRLF line endings; the split refused trailing whitespace,
188
+ // so this equals the cleartext digest of RFC 9580 section 7.1
189
+ content: { text: parts.signedText },
190
+ signatureType: plugins.openpgp.enums.signature.text,
191
+ date: input.date,
192
+ field: 'message',
193
+ headerHashAlgorithms: parts.headerHashAlgorithms,
194
+ });
195
+ return { ...verification, signedText: parts.signedText };
196
+ },
197
+ };
198
+ }
199
+
200
+ function hexByte(valueArg: number | null): string {
201
+ return valueArg === null ? 'none' : `0x${valueArg.toString(16).padStart(2, '0')}`;
202
+ }
203
+
204
+ function assertInputObject(inputArg: unknown): void {
205
+ if (inputArg === null || typeof inputArg !== 'object') {
206
+ throw new LinuxRepoError('invalid-input', 'input must be an object', { field: 'input' });
207
+ }
208
+ }
209
+
210
+ /**
211
+ * The verification steps, each with its own error code: structure
212
+ * (`malformed-signature`), issuer (`wrong-key`), the signature's own time
213
+ * rules (`bad-signature`), the issuing key's ability to sign at creation and
214
+ * at `date` (`unusable-key`), then the cryptographic check (`bad-signature`).
215
+ */
216
+ async function verifySignature(inputArg: {
217
+ key: plugins.openpgp.PublicKey;
218
+ packets: Uint8Array;
219
+ /** The signed document: bytes for a binary signature, text for a text signature. */
220
+ content: { binary: Uint8Array } | { text: string };
221
+ signatureType: plugins.openpgp.enums.signature;
222
+ date: Date;
223
+ field: string;
224
+ headerHashAlgorithms: number[];
225
+ }): Promise<IOpenPgpSignatureVerification> {
226
+ const { key, date, field } = inputArg;
227
+ const malformed = (messageArg: string, causeArg?: unknown) =>
228
+ new LinuxRepoError('malformed-signature', messageArg, { field }, causeArg === undefined ? undefined : { cause: causeArg });
229
+
230
+ // 1. structure, before any cryptography
231
+ const tags = readPacketTags(inputArg.packets, { code: 'malformed-signature', field });
232
+ if (tags.length !== 1 || tags[0] !== plugins.openpgp.enums.packet.signature) {
233
+ throw malformed(`expected exactly one signature packet, got packet tags [${tags.join(', ')}]`);
234
+ }
235
+ let signature: plugins.openpgp.Signature;
236
+ try {
237
+ signature = await plugins.openpgp.readSignature({ binarySignature: inputArg.packets, config: VERIFY_CONFIG });
238
+ } catch (error) {
239
+ throw malformed('the signature packet cannot be read', error);
240
+ }
241
+ const packet = signature.packets[0];
242
+ if (signature.packets.length !== 1 || !(packet instanceof plugins.openpgp.SignaturePacket)) {
243
+ throw malformed('expected exactly one signature packet');
244
+ }
245
+ if (packet.version !== 4) {
246
+ throw malformed(`only v4 signatures are accepted, got v${packet.version}`);
247
+ }
248
+ if (packet.signatureType !== inputArg.signatureType) {
249
+ throw malformed(`expected signature type ${hexByte(inputArg.signatureType)}, got ${hexByte(packet.signatureType)}`);
250
+ }
251
+ const hashId = packet.hashAlgorithm;
252
+ const hashAlgorithm = hashId === null ? undefined : HASH_ALGORITHMS.get(hashId);
253
+ if (hashId === null || hashAlgorithm === undefined) {
254
+ throw malformed(`hash algorithm ${hashId} is not SHA-256, SHA-384 or SHA-512`);
255
+ }
256
+ if (inputArg.headerHashAlgorithms.length > 0 && !inputArg.headerHashAlgorithms.includes(hashId)) {
257
+ throw malformed('the Hash: header does not name the signature hash algorithm');
258
+ }
259
+ if (packet.created === null) {
260
+ throw malformed('the signature has no creation time');
261
+ }
262
+ const issuerKeyId = packet.issuerKeyID.toHex();
263
+ if (!/^[0-9a-f]{16}$/.test(issuerKeyId) || /^0+$/.test(issuerKeyId)) {
264
+ throw malformed('the signature names no issuer, or the wildcard issuer');
265
+ }
266
+ if (packet.unknownSubpackets.some((subpacket) => subpacket.critical)) {
267
+ throw malformed('the signature has an unknown critical subpacket');
268
+ }
269
+ if (packet.unhashedSubpackets.some((subpacket) => subpacket.critical && !UNHASHED_SUBPACKETS_READ.has(subpacket.type))) {
270
+ throw malformed('the signature has a critical unhashed subpacket the verifier does not read');
271
+ }
272
+ if (packet.rawNotations.some((notation) => notation.critical)) {
273
+ throw malformed('the signature has a critical notation');
274
+ }
275
+
276
+ // 2. issuer: the primary key or one of its subkeys, by key ID and, when present, by fingerprint
277
+ const issuerFingerprint = packet.issuerFingerprint === null ? null : Buffer.from(packet.issuerFingerprint).toString('hex');
278
+ const issuers = [key, ...key.subkeys].filter(
279
+ (candidate) =>
280
+ candidate.getKeyID().equals(packet.issuerKeyID) && (issuerFingerprint === null || candidate.getFingerprint() === issuerFingerprint),
281
+ );
282
+ if (issuers.length === 0) {
283
+ throw new LinuxRepoError('wrong-key', 'the signature was not issued by this key or one of its subkeys', {
284
+ field,
285
+ value: (issuerFingerprint ?? issuerKeyId).toUpperCase(),
286
+ });
287
+ }
288
+
289
+ // 3. the signature's own time rules
290
+ if (packet.isExpired(date)) {
291
+ const reason = packet.created.getTime() > date.getTime() ? 'was created after the verification date' : 'has expired at the verification date';
292
+ throw new LinuxRepoError('bad-signature', `the signature ${reason}`, { field });
293
+ }
294
+
295
+ // 4. the issuing key or subkey must be able to sign at the creation time and at `date`
296
+ const signingKeyAt = async (whenArg: Date): Promise<plugins.openpgp.PublicKey | plugins.openpgp.Subkey> => {
297
+ let signingKey: plugins.openpgp.PublicKey | plugins.openpgp.Subkey;
298
+ try {
299
+ signingKey = await key.getSigningKey(packet.issuerKeyID, whenArg, undefined, VERIFY_CONFIG);
300
+ } catch (error) {
301
+ throw new LinuxRepoError(
302
+ 'unusable-key',
303
+ `key ${issuerKeyId.toUpperCase()} cannot sign at ${whenArg.toISOString()}`,
304
+ { field: 'publicKeyArmored', value: issuers[0].getFingerprint().toUpperCase() },
305
+ { cause: error },
306
+ );
307
+ }
308
+ if (!issuers.includes(signingKey)) {
309
+ throw new LinuxRepoError('unusable-key', `key ${issuerKeyId.toUpperCase()} cannot sign at ${whenArg.toISOString()}`, {
310
+ field: 'publicKeyArmored',
311
+ value: issuers[0].getFingerprint().toUpperCase(),
312
+ });
313
+ }
314
+ return signingKey;
315
+ };
316
+ const atCreation = await signingKeyAt(packet.created);
317
+ const atDate = await signingKeyAt(date);
318
+ if (atCreation !== atDate) {
319
+ throw new LinuxRepoError('unusable-key', `key ${issuerKeyId.toUpperCase()} is not the same signing key at creation and at the verification date`, {
320
+ field: 'publicKeyArmored',
321
+ value: issuers[0].getFingerprint().toUpperCase(),
322
+ });
323
+ }
324
+
325
+ // 5. cryptography
326
+ try {
327
+ const content = inputArg.content;
328
+ const { signatures } =
329
+ 'text' in content
330
+ ? await plugins.openpgp.verify({
331
+ message: await plugins.openpgp.createMessage({ text: content.text }),
332
+ signature,
333
+ verificationKeys: key,
334
+ date,
335
+ config: VERIFY_CONFIG,
336
+ })
337
+ : await plugins.openpgp.verify({
338
+ message: await plugins.openpgp.createMessage({ binary: content.binary }),
339
+ signature,
340
+ verificationKeys: key,
341
+ date,
342
+ format: 'binary',
343
+ config: VERIFY_CONFIG,
344
+ });
345
+ if (signatures.length !== 1) {
346
+ throw new Error(`openpgp.js evaluated ${signatures.length} signatures, expected 1`);
347
+ }
348
+ await signatures[0].verified;
349
+ } catch (error) {
350
+ throw new LinuxRepoError('bad-signature', 'the signature does not verify', { field }, { cause: error });
351
+ }
352
+ return {
353
+ signingKeyFingerprint: atDate.getFingerprint().toUpperCase(),
354
+ created: new Date(packet.created.getTime()),
355
+ hashAlgorithm,
356
+ };
357
+ }