@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.
@@ -14,6 +14,19 @@ import type { ILinuxPackageLimits } from './limits.js';
14
14
  * - `invalid-input`: a bad argument from the caller.
15
15
  * - `invalid-key`: a key the codec cannot use.
16
16
  * - `signing-failed`: a codec signer failed, or a signer returned something that is not signature bytes.
17
+ * - `malformed-signature`: an OpenPGP signature or cleartext-signed message the verifier refuses before
18
+ * any cryptography: armor, encoding, framing, packet count, version, signature type, a hash outside
19
+ * SHA-256/384/512, a `Hash:` header that does not name the signature's hash, a missing creation time,
20
+ * a missing or wildcard issuer, or an unknown critical subpacket or notation.
21
+ * - `wrong-key`: the signature's issuer is neither the verifier's primary key nor one of its subkeys, or
22
+ * its issuer fingerprint disagrees (`detail.value` carries the issuer in hex).
23
+ * - `unusable-key`: the issuing key or subkey cannot sign at the signature's creation time or at the
24
+ * verification date: expired, revoked, no sign flag, a bad binding or back-signature, created after
25
+ * the signature, or below the algorithm floor.
26
+ * - `bad-signature`: the cryptographic check failed, the signature was created after the verification
27
+ * date, or it has expired at that date.
28
+ *
29
+ * New codes arrive in minor releases: a consumer that switches over `code` needs a default branch.
17
30
  *
18
31
  * The codec never wraps an error it did not raise: an error from the caller's
19
32
  * source or from a caller-supplied signer propagates unchanged. An underlying
@@ -31,7 +44,11 @@ export type TLinuxRepoErrorCode =
31
44
  | 'digest-mismatch'
32
45
  | 'invalid-input'
33
46
  | 'invalid-key'
34
- | 'signing-failed';
47
+ | 'signing-failed'
48
+ | 'malformed-signature'
49
+ | 'wrong-key'
50
+ | 'unusable-key'
51
+ | 'bad-signature';
35
52
 
36
53
  /** Names the field, structure or limit an error is about. */
37
54
  export interface ILinuxRepoErrorDetail {
@@ -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;