Skip to content

Commit e330ba8

Browse files
committed
Add support for optional self-signed certificate transparency
1 parent f0583b6 commit e330ba8

5 files changed

Lines changed: 554 additions & 6 deletions

File tree

karma.conf.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ module.exports = function(config) {
2828
// Core code stubs are set in pkgJson.browser.
2929
"http-proxy-agent": require.resolve('./test/empty-stub.js'),
3030
"https-proxy-agent": require.resolve('./test/empty-stub.js'),
31+
"hardened-https-agent": require.resolve('./test/empty-stub.js'),
3132
"request-promise-native": require.resolve('./test/empty-stub.js'),
3233
"get-port": require.resolve('./test/empty-stub.js'),
3334
"dns2": require.resolve('./test/empty-stub.js'),

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@
122122
"dns2": "1.4.2",
123123
"form-data-encoder": "^1.7.2",
124124
"formdata-node": "^4.3.2",
125+
"hardened-https-agent": "^1.5.0",
125126
"http-proxy-agent": "^5.0.0",
126127
"karma": "^6.3.2",
127128
"karma-chai": "^0.1.0",
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { Buffer } from 'buffer';
2+
import * as nodeCrypto from 'crypto';
3+
import * as asn1X509 from '@peculiar/asn1-x509';
4+
import * as asn1Schema from '@peculiar/asn1-schema';
5+
import * as x509 from '@peculiar/x509';
6+
7+
const crypto = globalThis.crypto;
8+
9+
const P256_ORDER = BigInt('0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551');
10+
11+
const SCT_EXTENSION_OID = '1.3.6.1.4.1.11129.2.4.2';
12+
13+
// Backdate the SCT timestamp by 24h, matching the leaf cert's notBefore, so that
14+
// a verifier whose clock lags ours doesn't reject the SCT as future-dated. The
15+
// device log list's log 'usable' timestamp must be at least this far in the past
16+
// too, so an SCT never predates the log becoming usable.
17+
const SCT_TIMESTAMP_BACKDATE_MS = 24 * 60 * 60 * 1000;
18+
19+
// PKCS#8 template for a P-256 private key containing only the scalar.
20+
// OpenSSL derives the public point on import. Structure:
21+
// SEQUENCE { version 0, AlgorithmIdentifier { ecPublicKey, prime256v1 },
22+
// OCTET STRING { ECPrivateKey { version 1, OCTET STRING <32-byte scalar> } } }
23+
const EC_PKCS8_PREFIX = Buffer.from([
24+
0x30, 0x41, // SEQUENCE (65 bytes)
25+
0x02, 0x01, 0x00, // INTEGER 0 (version)
26+
0x30, 0x13, // SEQUENCE (19 bytes) - AlgorithmIdentifier
27+
0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, // OID ecPublicKey
28+
0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, // OID prime256v1
29+
0x04, 0x27, // OCTET STRING (39 bytes) - wrapping ECPrivateKey
30+
0x30, 0x25, // SEQUENCE (37 bytes) - ECPrivateKey
31+
0x02, 0x01, 0x01, // INTEGER 1 (version)
32+
0x04, 0x20 // OCTET STRING (32 bytes) - private scalar follows
33+
]);
34+
35+
/**
36+
* A CT log operator with a derived P-256 keypair. Holds the private key for
37+
* signing SCTs and the public key / logId for verification.
38+
*/
39+
export class CTLogOperator {
40+
constructor(
41+
private readonly privateKey: nodeCrypto.KeyObject,
42+
public readonly publicKey: Buffer, // SPKI DER
43+
public readonly logId: Buffer // SHA-256 of SPKI DER
44+
) {}
45+
46+
/**
47+
* Sign an SCT for the given pre-certificate TBS and issuer key hash,
48+
* returning the serialized SCT bytes per RFC 6962 Section 3.3.
49+
*/
50+
signSCT(protoTbsDer: ArrayBuffer, issuerKeyHash: Buffer): Buffer {
51+
const timestamp = BigInt(Date.now() - SCT_TIMESTAMP_BACKDATE_MS);
52+
53+
const tbsLen = Buffer.alloc(3);
54+
tbsLen[0] = (protoTbsDer.byteLength >> 16) & 0xff;
55+
tbsLen[1] = (protoTbsDer.byteLength >> 8) & 0xff;
56+
tbsLen[2] = protoTbsDer.byteLength & 0xff;
57+
58+
const timestampBuf = Buffer.alloc(8);
59+
timestampBuf.writeBigUInt64BE(timestamp);
60+
61+
// RFC 6962 Section 3.2: digitally-signed struct for precert SCTs
62+
const signedData = Buffer.concat([
63+
Buffer.from([0x00]), // sct_version = v1
64+
Buffer.from([0x00]), // signature_type = certificate_timestamp
65+
timestampBuf,
66+
Buffer.from([0x00, 0x01]), // entry_type = precert_entry
67+
issuerKeyHash,
68+
tbsLen,
69+
Buffer.from(protoTbsDer),
70+
Buffer.from([0x00, 0x00]) // extensions length = 0
71+
]);
72+
73+
const derSig = nodeCrypto.sign('sha256', signedData, this.privateKey);
74+
75+
const sigLenBuf = Buffer.alloc(2);
76+
sigLenBuf.writeUInt16BE(derSig.length);
77+
78+
return Buffer.concat([
79+
Buffer.from([0x00]), // version = v1
80+
this.logId, // log_id (32 bytes)
81+
timestampBuf,
82+
Buffer.from([0x00, 0x00]), // extensions length = 0
83+
Buffer.from([0x04]), // hash_algorithm = SHA-256
84+
Buffer.from([0x03]), // signature_algorithm = ECDSA
85+
sigLenBuf,
86+
derSig
87+
]);
88+
}
89+
}
90+
91+
/**
92+
* Derive two CT log operators deterministically from a CA certificate.
93+
* Uses HKDF-SHA256 over the cert's SubjectPublicKeyInfo to derive P-256 private
94+
* scalars.
95+
*/
96+
export function deriveCTLogOperators(
97+
caCert: x509.X509Certificate
98+
): [CTLogOperator, CTLogOperator] {
99+
const caSpki = Buffer.from(caCert.publicKey.rawData);
100+
101+
const operators = [1, 2].map((i) => {
102+
const rawScalar = nodeCrypto.hkdfSync(
103+
'sha256',
104+
caSpki,
105+
'httptoolkit-ct',
106+
'log-operator-' + i,
107+
32
108+
);
109+
110+
const privateKey = buildP256KeyFromScalar(rawScalar);
111+
const publicKey = nodeCrypto.createPublicKey(
112+
privateKey.export({ format: 'pem', type: 'pkcs8' })
113+
).export({ format: 'der', type: 'spki' });
114+
const logId = nodeCrypto.createHash('sha256').update(publicKey).digest();
115+
116+
return new CTLogOperator(privateKey, publicKey, logId);
117+
});
118+
119+
return operators as [CTLogOperator, CTLogOperator];
120+
}
121+
122+
/**
123+
* Embed SCTs into a certificate using a two-pass approach:
124+
* 1. The input certificate serves as the "proto-certificate" (no SCT extension)
125+
* 2. Extract its TBS, generate SCTs, add SCT extension, re-sign
126+
*/
127+
export async function embedSCTsAndSign(
128+
protoCert: x509.X509Certificate,
129+
ctLogOperators: [CTLogOperator, CTLogOperator],
130+
issuerKeyHash: Buffer,
131+
caKey: CryptoKey,
132+
signingAlgorithm: Parameters<typeof crypto.subtle.sign>[0]
133+
): Promise<ArrayBuffer> {
134+
const cert = asn1Schema.AsnConvert.parse(protoCert.rawData, asn1X509.Certificate);
135+
const protoTbsDer = asn1Schema.AsnConvert.serialize(cert.tbsCertificate);
136+
137+
const scts = ctLogOperators.map(op => op.signSCT(protoTbsDer, issuerKeyHash));
138+
139+
// Build the SignedCertificateTimestampList extension value.
140+
// Each SCT is prefixed with its 2-byte length, and the whole list is
141+
// prefixed with its 2-byte total length. This TLS-encoded list is then
142+
// wrapped in an ASN.1 OCTET STRING per RFC 6962.
143+
const serializedSCTs = Buffer.concat(scts.map(sct => {
144+
const len = Buffer.alloc(2);
145+
len.writeUInt16BE(sct.length);
146+
return Buffer.concat([len, sct]);
147+
}));
148+
const sctListLen = Buffer.alloc(2);
149+
sctListLen.writeUInt16BE(serializedSCTs.length);
150+
const sctList = Buffer.concat([sctListLen, serializedSCTs]);
151+
152+
const innerOctetString = asn1Schema.AsnConvert.serialize(
153+
new asn1Schema.OctetString(sctList)
154+
);
155+
156+
if (!cert.tbsCertificate.extensions) {
157+
cert.tbsCertificate.extensions = new asn1X509.Extensions();
158+
}
159+
cert.tbsCertificate.extensions.push(new asn1X509.Extension({
160+
extnID: SCT_EXTENSION_OID,
161+
critical: false,
162+
extnValue: new asn1Schema.OctetString(innerOctetString)
163+
}));
164+
165+
const finalTbsDer = asn1Schema.AsnConvert.serialize(cert.tbsCertificate);
166+
const rawSignature = await crypto.subtle.sign(signingAlgorithm, caKey, finalTbsDer);
167+
168+
// WebCrypto emits ECDSA signatures as raw r||s, but X.509 requires the DER
169+
// ECDSA-Sig-Value encoding. RSA signatures are already in the right form.
170+
cert.signatureValue = caKey.algorithm.name === 'ECDSA'
171+
? toArrayBuffer(ecdsaRawSignatureToDer(rawSignature))
172+
: rawSignature;
173+
174+
return asn1Schema.AsnConvert.serialize(cert);
175+
}
176+
177+
// Copy a view's bytes into a standalone ArrayBuffer (X.509 signatureValue is typed
178+
// as ArrayBuffer, not a typed-array view).
179+
function toArrayBuffer(view: Uint8Array): ArrayBuffer {
180+
const buffer = new ArrayBuffer(view.byteLength);
181+
new Uint8Array(buffer).set(view);
182+
return buffer;
183+
}
184+
185+
/**
186+
* Convert a WebCrypto ECDSA signature (raw IEEE-P1363 r||s) into the DER
187+
* ECDSA-Sig-Value (SEQUENCE { INTEGER r, INTEGER s }) X.509 expects.
188+
*/
189+
function ecdsaRawSignatureToDer(rawSignature: ArrayBuffer): Buffer {
190+
const raw = Buffer.from(rawSignature);
191+
const half = raw.length / 2;
192+
const body = Buffer.concat([
193+
derInteger(raw.subarray(0, half)),
194+
derInteger(raw.subarray(half))
195+
]);
196+
return Buffer.concat([Buffer.from([0x30]), derLength(body.length), body]);
197+
}
198+
199+
function derInteger(value: Buffer): Buffer {
200+
let start = 0;
201+
while (start < value.length - 1 && value[start] === 0) start++;
202+
let content = value.subarray(start);
203+
if (content[0] & 0x80) content = Buffer.concat([Buffer.from([0x00]), content]);
204+
return Buffer.concat([Buffer.from([0x02]), derLength(content.length), content]);
205+
}
206+
207+
function derLength(length: number): Buffer {
208+
if (length < 0x80) return Buffer.from([length]);
209+
if (length < 0x100) return Buffer.from([0x81, length]);
210+
return Buffer.from([0x82, (length >> 8) & 0xff, length & 0xff]);
211+
}
212+
213+
/**
214+
* Build a Node KeyObject for a P-256 private key from a raw 32-byte scalar.
215+
* Substitutes the scalar into a fixed PKCS#8 DER template and lets OpenSSL
216+
* derive the public point.
217+
*/
218+
function buildP256KeyFromScalar(rawScalar: ArrayBuffer): nodeCrypto.KeyObject {
219+
let scalar = BigInt('0x' + Buffer.from(rawScalar).toString('hex'));
220+
if (scalar >= P256_ORDER) scalar -= P256_ORDER;
221+
if (scalar === 0n) scalar = 1n;
222+
223+
const scalarBytes = Buffer.from(scalar.toString(16).padStart(64, '0'), 'hex');
224+
const pkcs8Der = Buffer.concat([EC_PKCS8_PREFIX, scalarBytes]);
225+
226+
return nodeCrypto.createPrivateKey({ key: pkcs8Der, format: 'der', type: 'pkcs8' });
227+
}

src/util/certificates.ts

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { Buffer } from 'buffer';
22
import * as fs from 'fs/promises';
3-
import { createPrivateKey } from 'crypto';
3+
import { createPrivateKey, createHash } from 'crypto';
44

55
import * as _ from 'lodash';
66

77
import * as x509 from '@peculiar/x509';
88
import * as asn1X509 from '@peculiar/asn1-x509';
99
import * as asn1Schema from '@peculiar/asn1-schema';
1010

11+
import { CTLogOperator, deriveCTLogOperators, embedSCTsAndSign } from './certificate-transparency';
12+
1113
const crypto = globalThis.crypto;
1214

1315
// SC-081v3 final phase (2029-03-15) limits leaf cert validity to 47 days (46 recommended).
@@ -50,6 +52,12 @@ export interface BaseCAOptions {
5052
* connections.
5153
*/
5254
organizationName?: string;
55+
56+
/**
57+
* Whether to embed Signed Certificate Timestamps (SCTs) from
58+
* derived CT log operators in generated leaf certificates.
59+
*/
60+
certificateTransparency?: boolean;
5361
}
5462

5563
export type PEM = string | string[] | Buffer | Buffer[];
@@ -92,6 +100,24 @@ const EC_CURVE_HASHES: { [namedCurve: string]: string } = {
92100
'P-521': 'SHA-512'
93101
};
94102

103+
// Pregenerated throwaway signatures for building a proto-certificate for CT without
104+
// actually signing it (X509CertificateGenerator's signature mode). The value is
105+
// discarded once we re-sign over the final TBS, but needs to validate.
106+
const RSA_PLACEHOLDER_SIGNATURE = new Uint8Array(256);
107+
const EC_PLACEHOLDER_SIGNATURES: { [namedCurve: string]: Uint8Array } = {
108+
'P-256': new Uint8Array(64),
109+
'P-384': new Uint8Array(96),
110+
'P-521': new Uint8Array(132)
111+
};
112+
113+
function placeholderSignature(caKey: CryptoKey): Uint8Array {
114+
if (caKey.algorithm.name === 'ECDSA') {
115+
return EC_PLACEHOLDER_SIGNATURES[(caKey.algorithm as EcKeyAlgorithm).namedCurve];
116+
}
117+
return RSA_PLACEHOLDER_SIGNATURE;
118+
}
119+
120+
95121
async function pemToCryptoKey(pem: string): Promise<CryptoKey> {
96122
// Node's createPrivateKey natively parses PKCS#1 ("BEGIN RSA PRIVATE KEY"),
97123
// SEC1 ("BEGIN EC PRIVATE KEY") and PKCS#8 ("BEGIN PRIVATE KEY") PEM, so we
@@ -297,8 +323,9 @@ export async function getCA(options: CAOptions): Promise<CA> {
297323
throw new Error('Unrecognized https options: you need to provide either a keyPath & certPath, or a key & cert.')
298324
}
299325

326+
const caKeyPem = certOptions.key.toString();
300327
const caCert = new x509.X509Certificate(certOptions.cert.toString());
301-
const caKey = await pemToCryptoKey(certOptions.key.toString());
328+
const caKey = await pemToCryptoKey(caKeyPem);
302329

303330
return new CA(caCert, caKey, options);
304331
}
@@ -322,6 +349,8 @@ export type { CA };
322349

323350
class CA {
324351
private options: BaseCAOptions;
352+
private ctLogOperators: [CTLogOperator, CTLogOperator] | undefined;
353+
private issuerKeyHash: Buffer | undefined;
325354

326355
constructor(
327356
private caCert: x509.X509Certificate,
@@ -343,6 +372,21 @@ class CA {
343372
)
344373
};
345374
}
375+
376+
if (this.options.certificateTransparency) {
377+
this.ctLogOperators = deriveCTLogOperators(caCert);
378+
this.issuerKeyHash = createHash('sha256')
379+
.update(Buffer.from(caCert.publicKey.rawData))
380+
.digest();
381+
}
382+
}
383+
384+
getCTLogDetails(): Array<{ logId: Buffer, publicKey: Buffer }> {
385+
if (!this.ctLogOperators) throw new Error('CT not enabled');
386+
return this.ctLogOperators.map(op => ({
387+
logId: Buffer.from(op.logId),
388+
publicKey: Buffer.from(op.publicKey)
389+
}));
346390
}
347391

348392
async generateCertificate(domain: string): Promise<GeneratedCertificate> {
@@ -429,24 +473,44 @@ class CA {
429473
}
430474
: KEY_PAIR_ALGO;
431475

432-
const certificate = await x509.X509CertificateGenerator.create({
476+
const certParams = {
433477
serialNumber: generateSerialNumber(),
434478
subject: subjectDistinguishedName,
435479
issuer: issuerDistinguishedName,
436480
notBefore,
437481
notAfter,
438482
signingAlgorithm,
439483
publicKey: leafKeyPair.publicKey,
440-
signingKey: this.caKey,
441484
extensions
442-
});
485+
};
486+
487+
let certPem: string;
488+
if (this.ctLogOperators) {
489+
// Build the cert without signing it (the generator's signature mode), so
490+
// we sign exactly once - over the final TBS that includes the SCT extension.
491+
// SCTs are signed over this proto TBS; the placeholder signature is discarded.
492+
const protoCert = await x509.X509CertificateGenerator.create({
493+
...certParams,
494+
signature: placeholderSignature(this.caKey)
495+
});
496+
const finalCertDer = await embedSCTsAndSign(
497+
protoCert, this.ctLogOperators, this.issuerKeyHash!, this.caKey, signingAlgorithm
498+
);
499+
certPem = arrayBufferToPem(finalCertDer, "CERTIFICATE");
500+
} else {
501+
const certificate = await x509.X509CertificateGenerator.create({
502+
...certParams,
503+
signingKey: this.caKey
504+
});
505+
certPem = certificate.toString("pem");
506+
}
443507

444508
const generatedCertificate: GeneratedCertificate = {
445509
key: arrayBufferToPem(
446510
await crypto.subtle.exportKey("pkcs8", leafKeyPair.privateKey as CryptoKey),
447511
"PRIVATE KEY"
448512
),
449-
cert: certificate.toString("pem"),
513+
cert: certPem,
450514
ca: this.caCert.toString("pem"),
451515
expiresAt: notAfter
452516
};

0 commit comments

Comments
 (0)