@push.rocks/smartlinuxrepo 1.0.0 → 1.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.
@@ -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
+ }