Files
tessera-ctl/apps/api/src/cert-manager/cert-manager.service.ts
T
schalli 59694642dd feat(09-05): implement convertCert + wire POST /convert (GREEN)
- Add FileResponse interface and FORMAT_MIME map to service
- Implement convertCert: parses any input format (PEM/DER/PFX/P7B) via
  same logic as parseCert; serializes to pem/der/p7b targetFormat
- DER output uses bytesToHex→Buffer.from(hex,'hex') to avoid utf-8
  corruption (Pitfall 1 / T-09-06)
- P7B output: pkcs7.createSignedData + pem.encode (PEM-wrapped PKCS7)
- Wrap all forge ops in try/catch → BadRequestException (T-09-01)
- Controller: add @Body('pemText') + reject when neither file nor pemText
- Fix: re-add NotImplementedException import for mergeCerts stub
- All 23 API cert-manager tests green (including 4 new convertCert)
2026-07-02 07:37:41 +02:00

536 lines
21 KiB
TypeScript

import { BadRequestException, Injectable, Logger, NotImplementedException } from '@nestjs/common';
import * as forge from 'node-forge';
// ---------------------------------------------------------------------------
// CertDetails — the structured result returned by parseCert
// ---------------------------------------------------------------------------
export interface CertDetails {
subject: { cn: string; o: string; ou: string; c: string };
issuer: { cn: string; o: string; c: string };
validity: { notBefore: string; notAfter: string; isExpired: boolean; daysLeft: number };
san: string[];
keyType: string; // "RSA" | "EC"
keyBits: number; // 2048, 4096, 256, ...
serialNumber: string;
signatureAlgorithm: string; // "sha256WithRSAEncryption", etc.
fingerprint: { sha1: string; sha256: string };
pemPreview: string;
}
// ---------------------------------------------------------------------------
// SplitResponse — the structured result returned by splitCerts
// ---------------------------------------------------------------------------
export interface SplitEntry {
index: number;
filename: string;
/** PEM content base64-encoded (one BEGIN CERTIFICATE block per entry) */
content: string;
subject: { cn: string };
validity: { notAfter: string };
}
export interface SplitResponse {
count: number;
certs: SplitEntry[];
}
// ---------------------------------------------------------------------------
// FileResponse — the structured result returned by convertCert / mergeCerts
// ---------------------------------------------------------------------------
export interface FileResponse {
/** Suggested download filename, e.g. "converted.der" */
filename: string;
/** Base64-encoded file content */
content: string;
/** MIME type for the download */
mimeType: string;
}
/** Map from target format key to MIME type */
const FORMAT_MIME: Record<string, string> = {
pem: 'application/x-pem-file',
der: 'application/x-x509-ca-cert',
p7b: 'application/x-pkcs7-certificates',
};
// ---------------------------------------------------------------------------
// Reverse OID map (OID string -> human-readable algorithm name)
// Built once at module load — node-forge's pki.oids is name->OID
// ---------------------------------------------------------------------------
function buildReverseOids(): Record<string, string> {
const result: Record<string, string> = {};
for (const [name, oid] of Object.entries(forge.pki.oids as Record<string, string>)) {
result[oid] = name;
}
return result;
}
const REVERSE_OIDS = buildReverseOids();
/**
* CertManagerService — server-side certificate operations.
*
* All cryptographic processing is ephemeral (upload → process → return).
* No data is persisted to disk or database.
*
* SECURITY NOTES:
* - Binary buffers MUST use toString('binary') for forge (never 'utf-8' — Pitfall 1)
* - Password parameters are never passed to the logger
* - All forge operations wrapped in try/catch → BadRequestException
*/
@Injectable()
export class CertManagerService {
private readonly logger = new Logger(CertManagerService.name);
// ---------------------------------------------------------------------------
// Shared helpers (used by all operation methods)
// ---------------------------------------------------------------------------
/**
* Detect the format of a certificate file from extension + content sniff.
* .cer is ambiguous — resolved by inspecting the first bytes of the buffer.
*/
detectFormat(
filename: string,
buffer: Buffer,
): 'pem' | 'der' | 'pfx' | 'p7b' {
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
const isPemContent = buffer.slice(0, 27).toString('ascii').includes('-----BEGIN');
if (ext === 'pfx' || ext === 'p12') return 'pfx';
if (ext === 'p7b' || ext === 'p7c') return 'p7b';
if (ext === 'der') return 'der';
if (ext === 'pem' || ext === 'crt') return 'pem';
if (ext === 'cer') return isPemContent ? 'pem' : 'der'; // .cer is ambiguous
// Fallback: sniff content
return isPemContent ? 'pem' : 'der';
}
/**
* Convert a Node.js Buffer to a forge ByteStringBuffer using 'binary' encoding.
*
* CRITICAL: Always use 'binary' encoding — UTF-8 corrupts DER/PFX/P7B binary data.
* See RESEARCH.md Pitfall 1.
*/
toForgeBuffer(buffer: Buffer): forge.util.ByteStringBuffer {
return forge.util.createBuffer(buffer.toString('binary'));
}
/**
* Compute SHA-1 or SHA-256 fingerprint of a certificate.
* Hash is computed over the DER-encoded bytes, returned as uppercase colon-joined hex.
*/
getFingerprint(cert: forge.pki.Certificate, algorithm: 'sha1' | 'sha256'): string {
const md = algorithm === 'sha1' ? forge.md.sha1.create() : forge.md.sha256.create();
const der = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes();
md.update(der);
return md.digest().toHex().match(/.{2}/g)!.join(':').toUpperCase();
}
/**
* Split a PEM string containing one or more concatenated certificates.
* Returns an array of parsed forge Certificate objects.
*/
parsePemChain(pem: string): forge.pki.Certificate[] {
const blocks =
pem.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? [];
return blocks.map((b) => forge.pki.certificateFromPem(b));
}
// ---------------------------------------------------------------------------
// parseCert — CERT-01 + CERT-05 (read half)
// ---------------------------------------------------------------------------
/**
* Parse a certificate from PEM text or an uploaded file (PEM/DER/PFX/P7B).
*
* Security contract (T-09-01, T-09-02):
* - All forge calls wrapped in try/catch → BadRequestException (never an unhandled 500)
* - Wrong PFX password → generic 400 message (password value never logged or echoed)
*/
async parseCert(input: {
file?: any;
pemText?: string;
password?: string;
}): Promise<CertDetails> {
const { file, pemText, password } = input;
let cert: forge.pki.Certificate;
try {
if (pemText) {
// ── PEM text input ──────────────────────────────────────────────────
const certs = this.parsePemChain(pemText);
if (certs.length === 0) {
throw new Error('No certificate block found in PEM text');
}
cert = certs[0];
} else if (file) {
const format = this.detectFormat(file.originalname as string, file.buffer as Buffer);
if (format === 'pem') {
// ── PEM file ───────────────────────────────────────────────────────
const pemStr = (file.buffer as Buffer).toString('utf-8');
const certs = this.parsePemChain(pemStr);
if (certs.length === 0) {
throw new Error('No certificate block found in PEM file');
}
cert = certs[0];
} else if (format === 'der') {
// ── DER binary file ────────────────────────────────────────────────
// CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1)
const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
cert = forge.pki.certificateFromAsn1(asn1);
} else if (format === 'pfx') {
// ── PFX/PKCS12 file ───────────────────────────────────────────────
// wrong password → forge throws → caught below → BadRequestException (T-09-02)
const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? '');
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
const bags = certBags[forge.pki.oids.certBag] ?? [];
if (bags.length === 0) {
throw new Error('No certificate bag found in PFX/PKCS12');
}
cert = bags[0].cert!;
} else {
// ── P7B/PKCS7 file — PEM-wrapped or binary DER (Pitfall 4) ────────
const isPemP7b = (file.buffer as Buffer)
.slice(0, 27)
.toString('ascii')
.includes('-----BEGIN');
let p7: any;
if (isPemP7b) {
p7 = forge.pkcs7.messageFromPem((file.buffer as Buffer).toString('utf-8'));
} else {
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
}
const p7Certs: forge.pki.Certificate[] = p7.certificates ?? [];
if (p7Certs.length === 0) {
throw new Error('No certificate found in P7B/PKCS7');
}
cert = p7Certs[0];
}
} else {
// Neither file nor pemText — controller should have rejected this already,
// but guard here too (BadRequestException is NOT caught by the outer try/catch below)
throw new BadRequestException('No file or PEM text provided');
}
} catch (err) {
// Re-throw BadRequestException as-is; convert everything else to 400
if (err instanceof BadRequestException) throw err;
// Do NOT log the password (T-09-02)
this.logger.warn('parseCert: failed to parse certificate (format/password error)');
throw new BadRequestException(
'Failed to parse certificate: invalid format or wrong password',
);
}
// ── Build CertDetails ──────────────────────────────────────────────────
try {
const notBefore = cert.validity.notBefore;
const notAfter = cert.validity.notAfter;
const now = new Date();
const isExpired = notAfter < now;
const daysLeft = Math.ceil(
(notAfter.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
// Key type and size
const pubKey = cert.publicKey as any;
let keyType = 'RSA';
let keyBits = 0;
if (pubKey.n) {
keyType = 'RSA';
keyBits = pubKey.n.bitLength();
} else if (pubKey.curve) {
keyType = 'EC';
// EC key size from curve params — estimate from key length
keyBits = pubKey.params?.curve?.q?.bitLength() ?? 0;
}
// Subject Alternative Names
const sanExt = cert.extensions?.find((e: any) => e.name === 'subjectAltName');
const san: string[] = ((sanExt as any)?.altNames ?? []).map((n: any) =>
n.type === 2 ? (n.value as string) : `IP:${(n.ip ?? n.value) as string}`,
);
// Signature algorithm — OID → human-readable name
const sigOid = (cert.siginfo as any)?.algorithmOid ?? '';
const signatureAlgorithm = REVERSE_OIDS[sigOid] ?? sigOid;
// Fingerprints
const sha1 = this.getFingerprint(cert, 'sha1');
const sha256 = this.getFingerprint(cert, 'sha256');
return {
subject: {
cn: cert.subject.getField('CN')?.value ?? '',
o: cert.subject.getField('O')?.value ?? '',
ou: cert.subject.getField('OU')?.value ?? '',
c: cert.subject.getField('C')?.value ?? '',
},
issuer: {
cn: cert.issuer.getField('CN')?.value ?? '',
o: cert.issuer.getField('O')?.value ?? '',
c: cert.issuer.getField('C')?.value ?? '',
},
validity: {
notBefore: notBefore.toISOString(),
notAfter: notAfter.toISOString(),
isExpired,
daysLeft,
},
san,
keyType,
keyBits,
serialNumber: cert.serialNumber,
signatureAlgorithm,
fingerprint: { sha1, sha256 },
pemPreview: forge.pki.certificateToPem(cert),
};
} catch (err) {
this.logger.warn('parseCert: failed to extract CertDetails fields');
throw new BadRequestException('Failed to extract certificate details');
}
}
// ---------------------------------------------------------------------------
// Remaining operation stubs (implemented in later plan slices)
// ---------------------------------------------------------------------------
/**
* Split a fullchain PEM or P7B/PKCS7 bundle into individual certificates.
*
* Security contract (T-09-01):
* - All forge calls wrapped in try/catch → BadRequestException on malformed input
*
* Security contract (T-09-03):
* - File size limit 5 MB enforced by FileInterceptor in the controller
*/
async splitCerts(input: {
file?: any;
password?: string;
}): Promise<SplitResponse> {
const { file } = input;
if (!file) {
throw new BadRequestException('No file provided');
}
let certs: forge.pki.Certificate[];
try {
const format = this.detectFormat(file.originalname as string, file.buffer as Buffer);
if (format === 'pem') {
// ── PEM chain (fullchain.pem, .crt — both map to 'pem' in detectFormat) ─
const pemStr = (file.buffer as Buffer).toString('utf-8');
certs = this.parsePemChain(pemStr);
if (certs.length === 0) {
throw new Error('No certificate blocks found in PEM file');
}
} else if (format === 'p7b') {
// ── P7B/PKCS7 bundle — PEM-wrapped or binary DER (Pitfall 4) ─────────
const isPemP7b = (file.buffer as Buffer)
.slice(0, 27)
.toString('ascii')
.includes('-----BEGIN');
let p7: any;
if (isPemP7b) {
// PEM-wrapped PKCS7 (e.g. -----BEGIN PKCS7-----)
p7 = forge.pkcs7.messageFromPem((file.buffer as Buffer).toString('utf-8'));
} else {
// Binary DER PKCS7
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
}
certs = (p7.certificates as forge.pki.Certificate[]) ?? [];
if (certs.length === 0) {
throw new Error('No certificates found in P7B/PKCS7 bundle');
}
} else {
// DER / PFX — not a valid chain/bundle format for splitting
throw new BadRequestException(
'Only PEM chains (.pem, .crt) and P7B bundles (.p7b) can be split',
);
}
} catch (err) {
if (err instanceof BadRequestException) throw err;
this.logger.warn('splitCerts: failed to parse bundle');
throw new BadRequestException('Failed to split certificates: invalid format or corrupted file');
}
// ── Build SplitResponse ────────────────────────────────────────────────
const certEntries: SplitEntry[] = certs.map((cert, index) => {
const pemStr = forge.pki.certificateToPem(cert);
const content = Buffer.from(pemStr, 'utf-8').toString('base64');
const cn: string = cert.subject.getField('CN')?.value ?? '';
const notAfter: string = cert.validity.notAfter.toISOString();
return {
index,
filename: `cert-${index + 1}.pem`,
content,
subject: { cn },
validity: { notAfter },
};
});
return {
count: certEntries.length,
certs: certEntries,
};
}
async mergeCerts(_input: {
files?: any[];
outputFormat: string;
password?: string;
}): Promise<never> {
throw new NotImplementedException('mergeCerts is not yet implemented');
}
/**
* Convert a certificate between PEM, DER, and P7B formats.
*
* Security contract (T-09-01, T-09-06):
* - All forge calls wrapped in try/catch → BadRequestException on malformed input
* - DER output built via bytesToHex → Buffer.from(hex, 'hex') → base64 (never utf-8 round-trip)
* - Password is never passed to the logger (T-09-02)
*/
async convertCert(input: {
file?: any;
pemText?: string;
targetFormat: string;
password?: string;
}): Promise<FileResponse> {
const { file, pemText, targetFormat, password } = input;
// ── Validate targetFormat ──────────────────────────────────────────────
if (!FORMAT_MIME[targetFormat]) {
throw new BadRequestException(
`Unsupported target format: "${targetFormat}". Supported: pem, der, p7b`,
);
}
let cert: forge.pki.Certificate;
try {
// ── Resolve input to a forge Certificate ────────────────────────────
if (pemText) {
// PEM text pasted by the user
const certs = this.parsePemChain(pemText);
if (certs.length === 0) {
throw new Error('No certificate block found in PEM text');
}
cert = certs[0];
} else if (file) {
const format = this.detectFormat(file.originalname as string, file.buffer as Buffer);
if (format === 'pem') {
const pemStr = (file.buffer as Buffer).toString('utf-8');
const certs = this.parsePemChain(pemStr);
if (certs.length === 0) {
throw new Error('No certificate block found in PEM file');
}
cert = certs[0];
} else if (format === 'der') {
// CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1)
const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
cert = forge.pki.certificateFromAsn1(asn1);
} else if (format === 'pfx') {
// PFX/PKCS12 — extract first cert bag (wrong password → BadRequestException)
const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? '');
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
const bags = certBags[forge.pki.oids.certBag] ?? [];
if (bags.length === 0) {
throw new Error('No certificate bag found in PFX/PKCS12');
}
cert = bags[0].cert!;
} else {
// P7B — extract first cert
const isPemP7b = (file.buffer as Buffer)
.slice(0, 27)
.toString('ascii')
.includes('-----BEGIN');
let p7: any;
if (isPemP7b) {
p7 = forge.pkcs7.messageFromPem((file.buffer as Buffer).toString('utf-8'));
} else {
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
}
const p7Certs: forge.pki.Certificate[] = p7.certificates ?? [];
if (p7Certs.length === 0) {
throw new Error('No certificate found in P7B/PKCS7');
}
cert = p7Certs[0];
}
} else {
throw new BadRequestException('No file or PEM text provided');
}
} catch (err) {
if (err instanceof BadRequestException) throw err;
// Password never logged (T-09-02)
this.logger.warn('convertCert: failed to parse input certificate');
throw new BadRequestException(
'Failed to parse certificate: invalid format or wrong password',
);
}
// ── Serialize to targetFormat ─────────────────────────────────────────
try {
let content: string;
if (targetFormat === 'pem') {
// PEM text → base64 via utf-8
const pemOut = forge.pki.certificateToPem(cert);
content = Buffer.from(pemOut, 'utf-8').toString('base64');
} else if (targetFormat === 'der') {
// DER binary — CRITICAL: bytesToHex → Buffer.from(hex, 'hex') → base64
// Avoids utf-8 round-trip corruption (RESEARCH Pitfall 1 / T-09-06)
const derHex = forge.util.bytesToHex(
forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(),
);
content = Buffer.from(derHex, 'hex').toString('base64');
} else {
// P7B — PEM-wrapped PKCS7 SignedData containing the certificate
const p7 = forge.pkcs7.createSignedData();
p7.addCertificate(cert);
const p7DerBytes = forge.asn1.toDer(p7.toAsn1()).getBytes();
const p7PemStr = forge.pem.encode({ type: 'PKCS7', body: p7DerBytes });
content = Buffer.from(p7PemStr, 'utf-8').toString('base64');
}
return {
filename: `converted.${targetFormat}`,
content,
mimeType: FORMAT_MIME[targetFormat],
};
} catch (err) {
this.logger.warn('convertCert: failed to serialize to target format');
throw new BadRequestException(
`Failed to convert certificate to ${targetFormat}: serialization error`,
);
}
}
// ---------------------------------------------------------------------------
// Internal helpers for later slices
// ---------------------------------------------------------------------------
/** Wrap a node-forge operation and re-throw as BadRequestException on failure */
protected _parseOrThrow<T>(fn: () => T, errorMsg: string): T {
try {
return fn();
} catch (_err) {
this.logger.warn(`Cert parse failed: ${errorMsg}`);
throw new BadRequestException(errorMsg);
}
}
}