|
| 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 | +} |
0 commit comments