Skip to content

Commit f604d32

Browse files
committed
feat: AIA cert fetch
1 parent 66b5333 commit f604d32

File tree

8 files changed

+102
-50
lines changed

8 files changed

+102
-50
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ As all the cryptography is handled by either "webcrypto" or a "pure-js" implemen
1212

1313
1. The ChaCha20-Poly1305 cipher is not supported via WebCrypto -- so we utilise `@stablelib/chacha20-poly1305` to provide this functionality.
1414
2. To handle X509 certificate validation we utilise `@peculiar/x509`
15+
3. Optionally a few other crypto dependencies are used when using the `pure-js` implementation. These can be excluded when using WebCrypto.
1516

1617
## Supported Crypto Suites & Versions
1718

@@ -52,6 +53,7 @@ As all the cryptography is handled by either "webcrypto" or a "pure-js" implemen
5253
### Certificates
5354
- The entire Mozilla CA store is supported
5455
- A few additional certificates have also been added. See `src/utils/root-ca.ts`
56+
- We also implement fetching intermediate certificates via AIA fetching. Any fetched certificates are also then verified against the root CA store.
5557

5658
## Install
5759

src/make-tls-client.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export function makeTLSClient({
3131
supportedProtocolVersions,
3232
signatureAlgorithms,
3333
applicationLayerProtocols,
34+
fetchCertificateBytes,
3435
write,
3536
onRead,
3637
onApplicationData,
@@ -242,8 +243,10 @@ export function makeTLSClient({
242243

243244
logger.debug({ len: certificates.length }, 'parsed certificates')
244245

245-
if(verifyServerCertificate) {
246-
await verifyCertificateChain(certificates, host, rootCAs)
246+
if(verifyServerCertificate && !certificatesVerified) {
247+
await verifyCertificateChain(
248+
certificates, host, logger, fetchCertificateBytes, rootCAs
249+
)
247250
logger.debug('verified certificate chain')
248251

249252
certificatesVerified = true
@@ -339,8 +342,10 @@ export function makeTLSClient({
339342

340343
logger.debug('verified server key share signature')
341344

342-
if(verifyServerCertificate) {
343-
await verifyCertificateChain(certificates, host, rootCAs)
345+
if(verifyServerCertificate && !certificatesVerified) {
346+
await verifyCertificateChain(
347+
certificates, host, logger, fetchCertificateBytes, rootCAs
348+
)
344349
logger.debug('verified certificate chain')
345350

346351
certificatesVerified = true

src/types/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
import type { X509Certificate } from './x509.ts'
2+
13
export * from './x509.ts'
24
export * from './tls.ts'
35
export * from './crypto.ts'
46
export * from './logger.ts'
57

68
declare global {
79
const TLS_ADDITIONAL_ROOT_CA_LIST: string[]
10+
/**
11+
* Store fetched intermediate certificates typically fetched via
12+
* the AIA extension here to avoid refetching
13+
*/
14+
const TLS_INTERMEDIATE_CA_CACHE: { [url: string]: X509Certificate }
815
}

src/types/tls.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ export type TLSConnectionOptions = TLSHelloBaseOptions & {
9191
* if provided, the server certificate will be verified against these root CAs
9292
*/
9393
rootCAs?: X509Certificate[]
94+
/**
95+
* Fetch certificate bytes from a URL
96+
* Used when the AIA extension is present in a certificate
97+
* to fetch the issuer certificate
98+
*/
99+
fetchCertificateBytes?(url: string): Promise<Uint8Array>
94100
}
95101

96102
export type TLSClientOptions = TLSConnectionOptions & TLSEventHandlers & {

src/types/x509.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export type X509Certificate<T = any> = {
2222
getAlternativeDNSNames(): string[]
2323
isIssuer(ofCert: X509Certificate<T>): boolean
2424
getPublicKey(): CertificatePublicKey
25+
/**
26+
* Get the Authority Information Access extension value,
27+
* tells us where to get issuer certificate from
28+
*/
29+
getAIAExtension(): string | undefined
2530
/**
2631
* verify this certificate issued the certificate passed
2732
* @param otherCert the supposedly issued certificate to verify

src/utils/additional-root-cas.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
/* eslint indent: 0 */
22

33
let TLS_ADDITIONAL_ROOT_CA_LIST
4+
let TLS_INTERMEDIATE_CA_CACHE
45
if(typeof globalThis === 'object' && globalThis) {
56
TLS_ADDITIONAL_ROOT_CA_LIST = (globalThis.TLS_ADDITIONAL_ROOT_CA_LIST ||= [])
7+
TLS_INTERMEDIATE_CA_CACHE = (globalThis.TLS_INTERMEDIATE_CA_CACHE ||= {})
68
} else if(typeof window === 'object' && window) {
79
TLS_ADDITIONAL_ROOT_CA_LIST = (window.TLS_ADDITIONAL_ROOT_CA_LIST ||= [])
10+
TLS_INTERMEDIATE_CA_CACHE = (window.TLS_INTERMEDIATE_CA_CACHE ||= {})
811
} else {
912
TLS_ADDITIONAL_ROOT_CA_LIST = []
13+
TLS_INTERMEDIATE_CA_CACHE = {}
1014
}
1115

1216
TLS_ADDITIONAL_ROOT_CA_LIST.push(

src/utils/parse-certificate.ts

Lines changed: 52 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import './additional-root-cas.js'
22
import { crypto } from '../crypto/index.ts'
3-
import type { CertificatePublicKey, CipherSuite, Key, TLSProcessContext, X509Certificate } from '../types/index.ts'
3+
import type { CertificatePublicKey, CipherSuite, Key, Logger, TLSProcessContext, X509Certificate } from '../types/index.ts'
44
import { SUPPORTED_NAMED_CURVE_MAP, SUPPORTED_SIGNATURE_ALGS, SUPPORTED_SIGNATURE_ALGS_MAP } from './constants.ts'
55
import { getHash } from './decryption-utils.ts'
66
import { areUint8ArraysEqual, asciiToUint8Array, concatenateUint8Arrays } from './generics.ts'
77
import { MOZILLA_ROOT_CA_LIST } from './mozilla-root-cas.ts'
88
import { expectReadWithLength, packWithLength } from './packets.ts'
9-
import { loadX509FromDer, loadX509FromPem } from './x509.ts'
9+
import { defaultFetchCertificateBytes, loadX509FromDer, loadX509FromPem } from './x509.ts'
1010

1111
type VerifySignatureOptions = {
1212
signature: Uint8Array
@@ -111,8 +111,7 @@ export async function verifyCertificateSignature({
111111
}
112112

113113
export async function getSignatureDataTls13(
114-
hellos: Uint8Array[] | Uint8Array,
115-
cipherSuite: CipherSuite
114+
hellos: Uint8Array[] | Uint8Array, cipherSuite: CipherSuite
116115
) {
117116
const handshakeHash = await getHash(hellos, cipherSuite)
118117
return concatenateUint8Arrays([
@@ -155,74 +154,81 @@ export async function getSignatureDataTls12(
155154
export async function verifyCertificateChain(
156155
chain: X509Certificate[],
157156
host: string,
157+
logger: Logger,
158+
fetchCertificateBytes = defaultFetchCertificateBytes,
158159
additionalRootCAs?: X509Certificate[]
159160
) {
160161
const rootCAs = [
161162
...loadRootCAs(),
162163
...(additionalRootCAs || [])
163164
]
164165

166+
const leaf = chain[0]
165167
const commonNames = [
166-
...chain[0].getSubjectField('CN'),
167-
...chain[0].getAlternativeDNSNames()
168+
...leaf.getSubjectField('CN'),
169+
...leaf.getAlternativeDNSNames()
168170
]
169171
if(!commonNames.some(cn => matchHostname(host, cn))) {
170172
throw new Error(`Certificate is not for host ${host}`)
171173
}
172174

175+
chain = [...chain] // clone to allow appending fetched certs
176+
for(let i = 0; i < chain.length; i++) {
177+
const cert = chain[i]
178+
const cn = cert.getSubjectField('CN')
179+
if(!cert.isWithinValidity()) {
180+
throw new Error(`Certificate ${cn} (i: ${i}) is outside validity`)
181+
}
173182

174-
const tmpChain = [...chain]
175-
let currentCert = tmpChain.shift()!
176-
let rootIssuer: X509Certificate<any> | undefined
177-
// look for issuers until we hit the end or find root CA that signed one of them
178-
while(!rootIssuer) {
179-
const cn = currentCert.getSubjectField('CN')
180-
181-
if(!currentCert.isWithinValidity()) {
182-
throw new Error(`Certificate ${cn} is not within validity period`)
183+
// look in our chain for issuer
184+
let issuer = findIssuer(chain.slice(i + 1), cert)
185+
// if not found, check in our root CAs
186+
if(!issuer) {
187+
issuer = findIssuer(rootCAs, cert)
183188
}
184189

185-
rootIssuer = rootCAs.find(r => r.isIssuer(currentCert))
190+
// if not found, we'll try fetching it via AIA extension
191+
if(!issuer) {
192+
const aiaExt = cert.getAIAExtension()
193+
if(!aiaExt) {
194+
throw new Error(`Missing issuer for certificate ${cn} (i: ${i})`)
195+
}
186196

187-
if(!rootIssuer) {
188-
const issuer = findIssuer(tmpChain, currentCert)
197+
if(TLS_INTERMEDIATE_CA_CACHE?.[aiaExt]) {
198+
issuer = TLS_INTERMEDIATE_CA_CACHE[aiaExt]
199+
} else {
200+
logger.debug(
201+
{ aiaExt, cn },
202+
'fetching issuer certificate via AIA extension'
203+
)
189204

190-
//in case there are orphan certificates in chain
191-
if(!issuer) {
192-
break
193-
}
205+
const bytes = await fetchCertificateBytes(aiaExt)
206+
issuer = await loadX509FromPem(bytes)
207+
// we'll need to verify this cert below too
208+
chain.push(issuer)
194209

195-
if(!(await issuer.cert.verifyIssued(currentCert))) {
196-
throw new Error(`Certificate ${cn} issue verification failed`)
210+
TLS_INTERMEDIATE_CA_CACHE[aiaExt] = issuer
197211
}
198-
199-
currentCert = issuer.cert
200-
//remove issuer cert from chain
201-
tmpChain.splice(issuer.index, 1)
202212
}
203-
}
204-
205-
if(!rootIssuer) {
206-
throw new Error('Root CA not found. Could not verify certificate')
207-
}
208213

209-
const verified = await rootIssuer.verifyIssued(currentCert)
210-
if(!verified) {
211-
throw new Error('Root CA did not issue certificate')
212-
}
214+
if(!issuer.isWithinValidity()) {
215+
throw new Error(`Issuer Cert ${cn} is not within validity period`)
216+
}
213217

214-
if(!rootIssuer.isWithinValidity()) {
215-
throw new Error('CA certificate is not within validity period')
218+
if(!(await issuer.verifyIssued(cert))) {
219+
const icn = issuer.getSubjectField('CN')
220+
throw new Error(
221+
`Verification of ${cn} failed by issuer ${icn} (i: ${i})`
222+
)
223+
}
216224
}
225+
}
217226

218-
function findIssuer(chain:X509Certificate[], cert: X509Certificate) {
219-
for(const [i, element] of chain.entries()) {
220-
if(element.isIssuer(cert)) {
221-
return { cert: element, index: i }
222-
}
227+
function findIssuer(chain: X509Certificate[], cert: X509Certificate) {
228+
for(const element of chain) {
229+
if(element.isIssuer(cert)) {
230+
return element
223231
}
224-
225-
return null
226232
}
227233
}
228234

src/utils/x509.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { crypto } from '../crypto/index.ts'
44
// not using types/index to avoid circular dependency
55
import type { SignatureAlgorithm, X509Certificate } from '../types/index.ts'
66

7+
const AIA_EXT_TYPE = '1.3.6.1.5.5.7.1.1'
8+
79
export function loadX509FromPem(
810
pem: string | Uint8Array
911
): X509Certificate<peculiar.X509Certificate> {
@@ -20,6 +22,11 @@ export function loadX509FromPem(
2022
const now = new Date()
2123
return now > cert.notBefore && now < cert.notAfter
2224
},
25+
getAIAExtension() {
26+
const aiaExt = cert
27+
.getExtension(AIA_EXT_TYPE) as peculiar.AuthorityInfoAccessExtension
28+
return aiaExt?.caIssuers?.find(obj => obj.type === 'url')?.value
29+
},
2330
getSubjectField(name) {
2431
return cert.subjectName.getField(name)
2532
},
@@ -122,4 +129,14 @@ function getSigAlgorithm(
122129
export function loadX509FromDer(der: Uint8Array) {
123130
// peculiar handles both
124131
return loadX509FromPem(der)
132+
}
133+
134+
export async function defaultFetchCertificateBytes(url: string) {
135+
const res = await fetch(url)
136+
if(!res.ok) {
137+
throw new Error(`Failed to fetch certificate from ${url}: ${res.statusText}`)
138+
}
139+
140+
const buffer = await res.arrayBuffer()
141+
return new Uint8Array<any>(buffer)
125142
}

0 commit comments

Comments
 (0)