@ultimat3/mail 23.0.0 → 25.0.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/CLAUDE.md +77 -12
- package/README.md +85 -14
- package/package.json +9 -8
- package/src/catalog.ts +3 -12
- package/src/delivery-event-errors.ts +148 -0
- package/src/delivery-event-ses.ts +101 -0
- package/src/delivery-event.ts +103 -0
- package/src/driver-env.ts +92 -19
- package/src/driver-resend.ts +2 -2
- package/src/driver-ses.ts +205 -0
- package/src/driver-smtp.ts +30 -5
- package/src/driver.ts +28 -6
- package/src/envelope-address.ts +34 -14
- package/src/errors.ts +52 -11
- package/src/events.ts +28 -0
- package/src/html.ts +5 -16
- package/src/index.ts +20 -10
- package/src/job.ts +16 -4
- package/src/layout.ts +3 -2
- package/src/mail.ts +25 -14
- package/src/mime.ts +28 -7
- package/src/render.ts +7 -5
- package/src/resend-event-receiver.ts +111 -0
- package/src/resend-signature.ts +93 -0
- package/src/retain-mime.ts +142 -0
- package/src/ses-event-receiver.ts +141 -0
- package/src/ses-failure.ts +114 -0
- package/src/smtp-client.ts +3 -3
- package/src/smtp-socket.ts +1 -1
- package/src/sns-certificate-cache.ts +111 -0
- package/src/sns-signature.ts +187 -0
- package/src/transform.ts +13 -3
- package/src/x509-spki.ts +67 -0
package/src/smtp-socket.ts
CHANGED
|
@@ -204,7 +204,7 @@ export function smtpStreamOver(runtime: BunConnect, target: SmtpTarget): Promise
|
|
|
204
204
|
stage: 'data',
|
|
205
205
|
detail: `the socket stopped accepting bytes for ${target.timeoutMs}ms`,
|
|
206
206
|
retryable: true,
|
|
207
|
-
fix: 'pass timeoutMs: 60_000 to
|
|
207
|
+
fix: 'pass timeoutMs: 60_000 to smtpMailDriver() in app.config.ts',
|
|
208
208
|
}),
|
|
209
209
|
);
|
|
210
210
|
}, target.timeoutMs);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Single responsibility: the SNS signing certificates a receiver has downloaded, bounded against a
|
|
2
|
+
// sender who forges messages. Any pinned URL reaches the download BEFORE a signature is checked,
|
|
3
|
+
// and an allowed topic ARN is not a secret, so a forger can name a fresh path per request. Four
|
|
4
|
+
// bounds hold that to a fixed cost: LRU positives, a negative TTL, an in-flight cap, coalescing.
|
|
5
|
+
|
|
6
|
+
import { type Clock, isUltimateError, renderThrowable, systemClock } from '@ultimat3/core';
|
|
7
|
+
import { deliveryProviderUnreachable } from './delivery-event-errors';
|
|
8
|
+
import { certificateSpki, pemCertificateDer } from './x509-spki';
|
|
9
|
+
|
|
10
|
+
/** PEM text for a certificate URL. Injected in tests; production GETs it over pinned https. */
|
|
11
|
+
export type SnsCertificateFetch = (url: string) => Promise<string>;
|
|
12
|
+
|
|
13
|
+
/** SNS uses a handful of certificates per region; eight keeps every live one with room to rotate. */
|
|
14
|
+
const MAX_CERTIFICATES = 8;
|
|
15
|
+
/** Downloads at once. A flood waits on nothing: past this it is refused with a 503 SNS retries. */
|
|
16
|
+
const MAX_IN_FLIGHT = 2;
|
|
17
|
+
/** How long a URL that failed to download is answered from memory instead of fetched again. */
|
|
18
|
+
const NEGATIVE_TTL_MS = 300_000;
|
|
19
|
+
/** Remembered failures. Bounded too, or the negative cache is the next thing to flood. */
|
|
20
|
+
const MAX_FAILURES = 256;
|
|
21
|
+
|
|
22
|
+
export interface SnsCertificateCache {
|
|
23
|
+
/** The SPKI for a pinned certificate URL — cached, coalesced, or refused. */
|
|
24
|
+
spkiFor(url: string): Promise<Uint8Array<ArrayBuffer>>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SnsCertificateCacheOptions {
|
|
28
|
+
readonly fetchCertificate: SnsCertificateFetch;
|
|
29
|
+
readonly clock?: Clock | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createSnsCertificateCache(
|
|
33
|
+
options: SnsCertificateCacheOptions,
|
|
34
|
+
): SnsCertificateCache {
|
|
35
|
+
const clock = options.clock ?? systemClock;
|
|
36
|
+
// Insertion order is recency order: a hit is deleted and re-set, so the first key is the LRU.
|
|
37
|
+
const verified = new Map<string, Uint8Array<ArrayBuffer>>();
|
|
38
|
+
const failedUntil = new Map<string, number>();
|
|
39
|
+
const inFlight = new Map<string, Promise<Uint8Array<ArrayBuffer>>>();
|
|
40
|
+
|
|
41
|
+
const remember = <V>(map: Map<string, V>, key: string, value: V, max: number): void => {
|
|
42
|
+
map.delete(key);
|
|
43
|
+
map.set(key, value);
|
|
44
|
+
if (map.size > max) {
|
|
45
|
+
const oldest = map.keys().next().value;
|
|
46
|
+
if (oldest !== undefined) map.delete(oldest);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
async function download(url: string): Promise<Uint8Array<ArrayBuffer>> {
|
|
51
|
+
let pem: string;
|
|
52
|
+
try {
|
|
53
|
+
pem = await options.fetchCertificate(url);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// The default download already names its failure; an injected one may throw anything.
|
|
56
|
+
if (isUltimateError(error)) throw error;
|
|
57
|
+
throw deliveryProviderUnreachable('certificate', renderThrowable(error));
|
|
58
|
+
}
|
|
59
|
+
const der = pemCertificateDer(pem);
|
|
60
|
+
const spki = der === undefined ? undefined : certificateSpki(der);
|
|
61
|
+
if (spki === undefined) {
|
|
62
|
+
throw deliveryProviderUnreachable(
|
|
63
|
+
'certificate',
|
|
64
|
+
'the answer was not a PEM X.509 certificate',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return new Uint8Array(spki);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
spkiFor(url) {
|
|
72
|
+
const hit = verified.get(url);
|
|
73
|
+
if (hit !== undefined) {
|
|
74
|
+
remember(verified, url, hit, MAX_CERTIFICATES);
|
|
75
|
+
return Promise.resolve(hit);
|
|
76
|
+
}
|
|
77
|
+
const pending = inFlight.get(url);
|
|
78
|
+
if (pending !== undefined) return pending;
|
|
79
|
+
const now = clock.monotonic();
|
|
80
|
+
const until = failedUntil.get(url);
|
|
81
|
+
if (until !== undefined && now < until) {
|
|
82
|
+
return Promise.reject(
|
|
83
|
+
deliveryProviderUnreachable('certificate', 'this URL failed to download moments ago'),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (inFlight.size >= MAX_IN_FLIGHT) {
|
|
87
|
+
return Promise.reject(
|
|
88
|
+
deliveryProviderUnreachable('certificate', 'too many certificate downloads in flight'),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const started = download(url).then(
|
|
92
|
+
(spki) => {
|
|
93
|
+
failedUntil.delete(url);
|
|
94
|
+
remember(verified, url, spki, MAX_CERTIFICATES);
|
|
95
|
+
return spki;
|
|
96
|
+
},
|
|
97
|
+
(error: unknown) => {
|
|
98
|
+
remember(failedUntil, url, clock.monotonic() + NEGATIVE_TTL_MS, MAX_FAILURES);
|
|
99
|
+
throw error;
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
inFlight.set(url, started);
|
|
103
|
+
// `finally` on a branch whose rejection is observed by the caller, never left unhandled.
|
|
104
|
+
started.then(
|
|
105
|
+
() => inFlight.delete(url),
|
|
106
|
+
() => inFlight.delete(url),
|
|
107
|
+
);
|
|
108
|
+
return started;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Single responsibility: prove an Amazon SNS HTTP(S) message was signed by SNS for a topic this
|
|
2
|
+
// app owns. The AWS-documented procedure: the topic first (any AWS account can make SNS sign for
|
|
3
|
+
// ITS topic), the certificate URL pinned to `https://sns.<topic region>.amazonaws.com`, the
|
|
4
|
+
// canonical string per message type, RSA-SHA1 (v1) or RSA-SHA256 (v2) through Web Crypto.
|
|
5
|
+
|
|
6
|
+
import { type Clock, systemClock } from '@ultimat3/core';
|
|
7
|
+
import { t, validate } from '@ultimat3/schema';
|
|
8
|
+
import { deliveryInstant } from './delivery-event';
|
|
9
|
+
import { deliveryEventInvalid, deliveryEventUnverified } from './delivery-event-errors';
|
|
10
|
+
import type { SnsCertificateCache } from './sns-certificate-cache';
|
|
11
|
+
|
|
12
|
+
/** The envelope fields the signature covers, plus the three that say how it was signed. */
|
|
13
|
+
export interface SnsMessage {
|
|
14
|
+
readonly Type: 'Notification' | 'SubscriptionConfirmation' | 'UnsubscribeConfirmation';
|
|
15
|
+
readonly MessageId: string;
|
|
16
|
+
readonly TopicArn: string;
|
|
17
|
+
readonly Message: string;
|
|
18
|
+
readonly Timestamp: string;
|
|
19
|
+
readonly Subject?: string | undefined;
|
|
20
|
+
readonly SubscribeURL?: string | undefined;
|
|
21
|
+
readonly Token?: string | undefined;
|
|
22
|
+
readonly SignatureVersion: string;
|
|
23
|
+
readonly Signature: string;
|
|
24
|
+
readonly SigningCertURL: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type { SnsCertificateFetch } from './sns-certificate-cache';
|
|
28
|
+
|
|
29
|
+
const envelope = t.object({
|
|
30
|
+
Type: t.enum(['Notification', 'SubscriptionConfirmation', 'UnsubscribeConfirmation']),
|
|
31
|
+
MessageId: t.string,
|
|
32
|
+
TopicArn: t.string,
|
|
33
|
+
Message: t.string,
|
|
34
|
+
Timestamp: t.string,
|
|
35
|
+
Subject: t.string.optional(),
|
|
36
|
+
SubscribeURL: t.string.optional(),
|
|
37
|
+
Token: t.string.optional(),
|
|
38
|
+
SignatureVersion: t.string.optional(),
|
|
39
|
+
Signature: t.string.optional(),
|
|
40
|
+
SigningCertURL: t.string.optional(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** `arn:aws:sns:<region>:<account>:<name>` — the region is what the certificate host is pinned to. */
|
|
44
|
+
const TOPIC_ARN =
|
|
45
|
+
/^arn:aws(?:-us-gov)?:sns:([a-z]{2}(?:-[a-z]+)+-\d{1,2}):\d{12}:[A-Za-z0-9_-]{1,256}(?:\.fifo)?$/;
|
|
46
|
+
|
|
47
|
+
/** AWS's documented key order, per message type. `Subject` is signed only when present. */
|
|
48
|
+
const CONFIRMATION_KEYS: readonly (keyof SnsMessage)[] = [
|
|
49
|
+
'Message',
|
|
50
|
+
'MessageId',
|
|
51
|
+
'SubscribeURL',
|
|
52
|
+
'Timestamp',
|
|
53
|
+
'Token',
|
|
54
|
+
'TopicArn',
|
|
55
|
+
'Type',
|
|
56
|
+
];
|
|
57
|
+
/** A Map: `Type` came off the wire, and an object table answers `constructor` from its prototype. */
|
|
58
|
+
const SIGNED_KEYS: ReadonlyMap<SnsMessage['Type'], readonly (keyof SnsMessage)[]> = new Map([
|
|
59
|
+
['Notification', ['Message', 'MessageId', 'Subject', 'Timestamp', 'TopicArn', 'Type']],
|
|
60
|
+
['SubscriptionConfirmation', CONFIRMATION_KEYS],
|
|
61
|
+
['UnsubscribeConfirmation', CONFIRMATION_KEYS],
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** `SignatureVersion` is a wire value: `"constructor"` must find nothing here. */
|
|
65
|
+
const HASH_OF: ReadonlyMap<string, 'SHA-1' | 'SHA-256'> = new Map([
|
|
66
|
+
['1', 'SHA-1'],
|
|
67
|
+
['2', 'SHA-256'],
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/** `"Key\nValue\n"` for each signed key present, in the documented order. */
|
|
71
|
+
export function snsCanonicalString(message: SnsMessage): string {
|
|
72
|
+
let canonical = '';
|
|
73
|
+
for (const key of SIGNED_KEYS.get(message.Type) ?? []) {
|
|
74
|
+
const value = message[key];
|
|
75
|
+
if (value !== undefined) canonical += `${key}\n${value}\n`;
|
|
76
|
+
}
|
|
77
|
+
return canonical;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The region of an ARN this module accepts, or `undefined` for one it does not. */
|
|
81
|
+
export function snsTopicRegion(topicArn: string): string | undefined {
|
|
82
|
+
return TOPIC_ARN.exec(topicArn)?.[1];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The only path SNS serves a signing certificate from. */
|
|
86
|
+
/** `SimpleNotificationService-<32 lower-case hex>.pem` — the form every published SigningCertURL takes. */
|
|
87
|
+
const CERT_PATH = /^\/SimpleNotificationService-[0-9a-f]{32}\.pem$/;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A signing-certificate URL in its ONE spelling: `https://sns.<region>.amazonaws.com/SimpleNotificationService-<hex>.pem`,
|
|
91
|
+
* compared as a string against what the parse normalises it to. Every alias of one URL — a
|
|
92
|
+
* fragment, `:443`, a bare `?`, `\` for `/`, an upper-case or full-width host — is refused rather
|
|
93
|
+
* than normalised, so one certificate has one cache key. That does NOT stop a forger who knows an
|
|
94
|
+
* allowed topic from naming a new `<hex>` per request — every one passes here and reaches the
|
|
95
|
+
* download before any signature is checked. `sns-certificate-cache.ts` is what bounds that cost.
|
|
96
|
+
*/
|
|
97
|
+
export function isPinnedCertificateUrl(raw: string, region: string): boolean {
|
|
98
|
+
const url = URL.parse(raw);
|
|
99
|
+
if (url === null || !CERT_PATH.test(url.pathname)) return false;
|
|
100
|
+
return raw === `https://sns.${region}.amazonaws.com${url.pathname}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A SubscribeURL: the pinned origin, path `/`, a query, no fragment, no userinfo, no port. */
|
|
104
|
+
export function isPinnedSubscribeUrl(raw: string, region: string): boolean {
|
|
105
|
+
const url = URL.parse(raw);
|
|
106
|
+
if (url === null || url.hash !== '' || url.pathname !== '/' || url.search === '') return false;
|
|
107
|
+
return raw.startsWith(`https://sns.${region}.amazonaws.com/?`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function base64Bytes(text: string): Uint8Array<ArrayBuffer> | undefined {
|
|
111
|
+
try {
|
|
112
|
+
return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface SnsVerifyOptions {
|
|
119
|
+
/** Every topic this app subscribed this route to. Anything else is refused before any fetch. */
|
|
120
|
+
readonly topicArns: ReadonlySet<string>;
|
|
121
|
+
readonly toleranceMs: number;
|
|
122
|
+
readonly clock?: Clock | undefined;
|
|
123
|
+
/** Downloaded certificates, bounded against forgery floods. The receiver owns one. */
|
|
124
|
+
readonly certificates: SnsCertificateCache;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Parse, pin, fetch, verify, then check the clock — and answer the message that was signed. */
|
|
128
|
+
export async function verifySnsMessage(
|
|
129
|
+
parsed: unknown,
|
|
130
|
+
options: SnsVerifyOptions,
|
|
131
|
+
): Promise<SnsMessage> {
|
|
132
|
+
const result = validate(envelope, parsed);
|
|
133
|
+
if (result.issues !== undefined) throw deliveryEventInvalid('ses', 'envelope');
|
|
134
|
+
const fields = result.value;
|
|
135
|
+
const { Signature: signature, SigningCertURL: certUrl, SignatureVersion: version } = fields;
|
|
136
|
+
if (signature === undefined || certUrl === undefined || version === undefined) {
|
|
137
|
+
throw deliveryEventUnverified('ses', 'unsigned');
|
|
138
|
+
}
|
|
139
|
+
if (!options.topicArns.has(fields.TopicArn)) throw deliveryEventUnverified('ses', 'topic');
|
|
140
|
+
const region = snsTopicRegion(fields.TopicArn);
|
|
141
|
+
if (region === undefined || !isPinnedCertificateUrl(certUrl, region)) {
|
|
142
|
+
throw deliveryEventUnverified('ses', 'certificate-url');
|
|
143
|
+
}
|
|
144
|
+
const hash = HASH_OF.get(version);
|
|
145
|
+
if (hash === undefined) throw deliveryEventUnverified('ses', 'signature-version');
|
|
146
|
+
const message: SnsMessage = {
|
|
147
|
+
...fields,
|
|
148
|
+
SignatureVersion: version,
|
|
149
|
+
Signature: signature,
|
|
150
|
+
SigningCertURL: certUrl,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const signatureBytes = base64Bytes(signature);
|
|
154
|
+
const spki = await options.certificates.spkiFor(certUrl);
|
|
155
|
+
if (signatureBytes === undefined || !(await rsaVerifies(spki, hash, signatureBytes, message))) {
|
|
156
|
+
throw deliveryEventUnverified('ses', 'signature');
|
|
157
|
+
}
|
|
158
|
+
// After the signature, like `@ultimat3/http`'s receiver: "stale" then always means authentic.
|
|
159
|
+
const sentAt = deliveryInstant('ses', fields.Timestamp).getTime();
|
|
160
|
+
const now = (options.clock ?? systemClock).now().getTime();
|
|
161
|
+
if (Math.abs(now - sentAt) > options.toleranceMs) {
|
|
162
|
+
throw deliveryEventUnverified('ses', 'stale');
|
|
163
|
+
}
|
|
164
|
+
return message;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function rsaVerifies(
|
|
168
|
+
spki: Uint8Array<ArrayBuffer>,
|
|
169
|
+
hash: 'SHA-1' | 'SHA-256',
|
|
170
|
+
signature: Uint8Array<ArrayBuffer>,
|
|
171
|
+
message: SnsMessage,
|
|
172
|
+
): Promise<boolean> {
|
|
173
|
+
try {
|
|
174
|
+
const key = await crypto.subtle.importKey(
|
|
175
|
+
'spki',
|
|
176
|
+
spki,
|
|
177
|
+
{ name: 'RSASSA-PKCS1-v1_5', hash },
|
|
178
|
+
false,
|
|
179
|
+
['verify'],
|
|
180
|
+
);
|
|
181
|
+
const canonical = new TextEncoder().encode(snsCanonicalString(message));
|
|
182
|
+
return await crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, signature, canonical);
|
|
183
|
+
} catch {
|
|
184
|
+
// A key Web Crypto will not import (not RSA) verifies nothing.
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
package/src/transform.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// every retry all carry the transformed bytes. An app adds an open pixel or rewrites its own links
|
|
4
4
|
// here; the framework only guarantees WHEN it runs, what it sees, and that a failure never ships.
|
|
5
5
|
|
|
6
|
+
import { stringField } from '@ultimat3/core';
|
|
6
7
|
import type { MailMessage } from './driver';
|
|
7
8
|
import { transformFailed } from './errors';
|
|
8
9
|
import { assertHeaderSafe } from './header-safety';
|
|
@@ -98,11 +99,20 @@ function asRendered(value: unknown): MailRendered | undefined {
|
|
|
98
99
|
|
|
99
100
|
/**
|
|
100
101
|
* What KIND of value was thrown, from a closed list — never its message and never its `name`,
|
|
101
|
-
* which are the app's strings and may carry a recipient address.
|
|
102
|
+
* which are the app's strings and may carry a recipient address. TOTAL, because it runs inside the
|
|
103
|
+
* catch that codes the failure: `instanceof` runs a `Proxy`'s `getPrototypeOf` trap and `.name` is a
|
|
104
|
+
* getter, and either throwing there escaped `send()` uncoded. `typeof` alone cannot throw.
|
|
102
105
|
*/
|
|
103
106
|
const KNOWN_ERRORS = new Set(['Error', 'TypeError', 'RangeError', 'SyntaxError', 'ReferenceError']);
|
|
104
107
|
|
|
105
108
|
function describe(error: unknown): string {
|
|
106
|
-
|
|
107
|
-
|
|
109
|
+
let isError: boolean;
|
|
110
|
+
try {
|
|
111
|
+
isError = error instanceof Error;
|
|
112
|
+
} catch {
|
|
113
|
+
return typeof error;
|
|
114
|
+
}
|
|
115
|
+
if (!isError) return typeof error;
|
|
116
|
+
const name = stringField(error, 'name');
|
|
117
|
+
return name !== undefined && KNOWN_ERRORS.has(name) ? name : 'an Error';
|
|
108
118
|
}
|
package/src/x509-spki.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Single responsibility: the public key out of a PEM X.509 certificate, as the SubjectPublicKeyInfo
|
|
2
|
+
// bytes Web Crypto imports (`importKey('spki', …)`). Web Crypto takes no certificate, so this walks
|
|
3
|
+
// just enough DER to find the seventh field of `tbsCertificate` (RFC 5280 §4.1). Malformed input is
|
|
4
|
+
// `undefined`, never a throw: the bytes came off a network.
|
|
5
|
+
|
|
6
|
+
interface Tlv {
|
|
7
|
+
readonly tag: number;
|
|
8
|
+
/** Offset of the tag byte. */
|
|
9
|
+
readonly start: number;
|
|
10
|
+
/** Offset of the first content byte. */
|
|
11
|
+
readonly body: number;
|
|
12
|
+
/** Offset one past the last content byte. */
|
|
13
|
+
readonly end: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SEQUENCE = 0x30;
|
|
17
|
+
const VERSION = 0xa0;
|
|
18
|
+
/** `serialNumber`, `signature`, `issuer`, `validity`, `subject` — skipped to reach the key. */
|
|
19
|
+
const FIELDS_BEFORE_KEY = 5;
|
|
20
|
+
|
|
21
|
+
/** One DER tag-length-value at `at`, bounded by `limit`. Definite lengths only, as DER requires. */
|
|
22
|
+
function readTlv(bytes: Uint8Array, at: number, limit: number): Tlv | undefined {
|
|
23
|
+
if (at + 2 > limit) return undefined;
|
|
24
|
+
const tag = bytes[at] ?? 0;
|
|
25
|
+
const first = bytes[at + 1] ?? 0;
|
|
26
|
+
let body = at + 2;
|
|
27
|
+
let length = first;
|
|
28
|
+
if (first & 0x80) {
|
|
29
|
+
const count = first & 0x7f;
|
|
30
|
+
if (count === 0 || count > 4 || body + count > limit) return undefined;
|
|
31
|
+
length = 0;
|
|
32
|
+
for (let index = 0; index < count; index += 1)
|
|
33
|
+
length = length * 256 + (bytes[body + index] ?? 0);
|
|
34
|
+
body += count;
|
|
35
|
+
}
|
|
36
|
+
const end = body + length;
|
|
37
|
+
return end > limit ? undefined : { tag, start: at, body, end };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The DER inside the first `CERTIFICATE` block, or `undefined` for anything else. */
|
|
41
|
+
export function pemCertificateDer(pem: string): Uint8Array | undefined {
|
|
42
|
+
const match = /-----BEGIN CERTIFICATE-----([A-Za-z0-9+/=\s]+)-----END CERTIFICATE-----/.exec(pem);
|
|
43
|
+
if (match === null) return undefined;
|
|
44
|
+
try {
|
|
45
|
+
const binary = atob((match[1] ?? '').replace(/\s+/g, ''));
|
|
46
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `Certificate → tbsCertificate → subjectPublicKeyInfo`, the whole element, tag included. */
|
|
53
|
+
export function certificateSpki(der: Uint8Array): Uint8Array | undefined {
|
|
54
|
+
const certificate = readTlv(der, 0, der.byteLength);
|
|
55
|
+
if (certificate?.tag !== SEQUENCE) return undefined;
|
|
56
|
+
const tbs = readTlv(der, certificate.body, certificate.end);
|
|
57
|
+
if (tbs?.tag !== SEQUENCE) return undefined;
|
|
58
|
+
let field = readTlv(der, tbs.body, tbs.end);
|
|
59
|
+
// `[0] EXPLICIT version` is absent on a v1 certificate and present on every v3 one.
|
|
60
|
+
if (field?.tag === VERSION) field = readTlv(der, field.end, tbs.end);
|
|
61
|
+
for (let skipped = 0; skipped < FIELDS_BEFORE_KEY; skipped += 1) {
|
|
62
|
+
if (field === undefined) return undefined;
|
|
63
|
+
field = readTlv(der, field.end, tbs.end);
|
|
64
|
+
}
|
|
65
|
+
if (field?.tag !== SEQUENCE) return undefined;
|
|
66
|
+
return der.slice(field.start, field.end);
|
|
67
|
+
}
|