import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, LessThan } from 'typeorm'; import { ConfigService } from '@nestjs/config'; import * as crypto from 'crypto'; import * as forge from 'node-forge'; import { DeviceCertificate, CertificateStatus, CertificateType } from '../../entities/device-certificate.entity'; import { Device } from '../../entities/device.entity'; export interface CertificateGenerationOptions { deviceId: string; commonName: string; organization?: string; organizationalUnit?: string; country?: string; validityDays?: number; keySize?: number; algorithm?: 'RSA' | 'ECDSA'; } export interface CertificateInfo { serialNumber: string; fingerprint: string; subjectDn: string; issuerDn: string; validFrom: Date; validTo: Date; keyAlgorithm: string; keySize: number; signatureAlgorithm: string; } export interface CertificateValidationResult { valid: boolean; expired: boolean; revoked: boolean; trustChainValid: boolean; errors: string[]; warnings: string[]; } @Injectable() export class CertificateService { private readonly logger = new Logger(CertificateService.name); private caCertificate: forge.pki.Certificate | null = null; private caPrivateKey: forge.pki.PrivateKey | null = null; constructor( @InjectRepository(DeviceCertificate) private certificateRepository: Repository, @InjectRepository(Device) private deviceRepository: Repository, private configService: ConfigService, ) { this.initializeCa(); } /** * Generates a new device certificate */ async generateDeviceCertificate(options: CertificateGenerationOptions): Promise { try { // Verify device exists const device = await this.deviceRepository.findOne({ where: { id: options.deviceId }, }); if (!device) { throw new NotFoundException(`Device ${options.deviceId} not found`); } // Check if device already has an active certificate const existingCert = await this.certificateRepository.findOne({ where: { deviceId: options.deviceId, status: CertificateStatus.ACTIVE, }, }); if (existingCert) { // Revoke existing certificate await this.revokeCertificate(existingCert.id, 'superseded'); } // Generate key pair const keyPair = this.generateKeyPair(options.algorithm || 'RSA', options.keySize || 2048); // Create certificate const cert = forge.pki.createCertificate(); cert.publicKey = keyPair.publicKey; cert.serialNumber = this.generateSerialNumber(); cert.validity.notBefore = new Date(); cert.validity.notAfter = new Date(); cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + (options.validityDays || 365)); // Set subject const subject = [ { name: 'commonName', value: options.commonName }, { name: 'organizationName', value: options.organization || 'Meteor Network' }, { name: 'organizationalUnitName', value: options.organizationalUnit || 'Edge Devices' }, { name: 'countryName', value: options.country || 'US' }, ]; cert.setSubject(subject); // Set issuer (CA) if (this.caCertificate) { cert.setIssuer(this.caCertificate.subject.attributes); } else { cert.setIssuer(subject); // Self-signed for development } // Add extensions cert.setExtensions([ { name: 'basicConstraints', cA: false, critical: true, }, { name: 'keyUsage', digitalSignature: true, keyEncipherment: true, critical: true, }, { name: 'extKeyUsage', clientAuth: true, serverAuth: false, }, { name: 'subjectAltName', altNames: [ { type: 2, // DNS value: `device-${options.deviceId}.meteor-network.local`, }, { type: 7, // IP ip: '127.0.0.1', }, ], }, { name: 'subjectKeyIdentifier', }, { name: 'authorityKeyIdentifier', keyIdentifier: (this.caCertificate?.getExtension('subjectKeyIdentifier') as any)?.subjectKeyIdentifier || false, }, ]); // Sign certificate const signingKey = this.caPrivateKey || keyPair.privateKey; cert.sign(signingKey as any, forge.md.sha256.create()); // Convert to PEM format const certificatePem = forge.pki.certificateToPem(cert); const privateKeyPem = forge.pki.privateKeyToPem(keyPair.privateKey); const publicKeyPem = forge.pki.publicKeyToPem(keyPair.publicKey); // Calculate fingerprint const fingerprint = this.calculateCertificateFingerprint(certificatePem); // Save to database const deviceCertificate = this.certificateRepository.create({ deviceId: options.deviceId, serialNumber: cert.serialNumber, fingerprint, certificateType: CertificateType.DEVICE, status: CertificateStatus.ACTIVE, subjectDn: this.formatDistinguishedName(cert.subject.attributes), issuerDn: this.formatDistinguishedName(cert.issuer.attributes), certificatePem, privateKeyPem, publicKeyPem, keyAlgorithm: options.algorithm || 'RSA', keySize: options.keySize || 2048, signatureAlgorithm: 'SHA256withRSA', issuedAt: cert.validity.notBefore, expiresAt: cert.validity.notAfter, x509Extensions: { key_usage: ['digitalSignature', 'keyEncipherment'], extended_key_usage: ['clientAuth'], subject_alt_name: [`DNS:device-${options.deviceId}.meteor-network.local`], basic_constraints: { ca: false }, }, }); const savedCertificate = await this.certificateRepository.save(deviceCertificate); this.logger.log(`Generated certificate ${savedCertificate.serialNumber} for device ${options.deviceId}`); return savedCertificate; } catch (error) { this.logger.error('Error generating device certificate:', error); throw new BadRequestException('Failed to generate device certificate'); } } /** * Validates a certificate */ async validateCertificate(certificatePem: string): Promise { try { const cert = forge.pki.certificateFromPem(certificatePem); const now = new Date(); const errors: string[] = []; const warnings: string[] = []; // Check expiration const expired = now > cert.validity.notAfter || now < cert.validity.notBefore; if (expired) { errors.push('Certificate is expired or not yet valid'); } // Check if certificate is in database and revoked const dbCertificate = await this.certificateRepository.findOne({ where: { serialNumber: cert.serialNumber }, }); const revoked = dbCertificate?.status === CertificateStatus.REVOKED; if (revoked) { errors.push('Certificate has been revoked'); } // Validate certificate chain let trustChainValid = true; try { if (this.caCertificate) { const caStore = forge.pki.createCaStore([this.caCertificate]); trustChainValid = forge.pki.verifyCertificateChain(caStore, [cert]); } } catch (error) { trustChainValid = false; errors.push('Certificate chain validation failed'); } // Check certificate extensions const keyUsageExt = cert.getExtension('keyUsage'); if (!keyUsageExt || !(keyUsageExt as any).digitalSignature) { warnings.push('Certificate missing required digitalSignature key usage'); } // Check if certificate is close to expiration (30 days) const daysToExpiration = Math.ceil((cert.validity.notAfter.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); if (daysToExpiration <= 30 && daysToExpiration > 0) { warnings.push(`Certificate expires in ${daysToExpiration} days`); } return { valid: errors.length === 0, expired, revoked, trustChainValid, errors, warnings, }; } catch (error) { this.logger.error('Error validating certificate:', error); return { valid: false, expired: false, revoked: false, trustChainValid: false, errors: ['Invalid certificate format'], warnings: [], }; } } /** * Revokes a certificate */ async revokeCertificate(certificateId: string, reason: string): Promise { const certificate = await this.certificateRepository.findOne({ where: { id: certificateId }, }); if (!certificate) { throw new NotFoundException(`Certificate ${certificateId} not found`); } if (certificate.status === CertificateStatus.REVOKED) { throw new BadRequestException('Certificate is already revoked'); } certificate.status = CertificateStatus.REVOKED; certificate.revokedAt = new Date(); certificate.revocationReason = reason; await this.certificateRepository.save(certificate); this.logger.warn(`Certificate ${certificate.serialNumber} revoked: ${reason}`); } /** * Renews a certificate before expiration */ async renewCertificate(certificateId: string): Promise { const existingCert = await this.certificateRepository.findOne({ where: { id: certificateId }, relations: ['device'], }); if (!existingCert) { throw new NotFoundException(`Certificate ${certificateId} not found`); } const daysToExpiration = Math.ceil((existingCert.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)); if (daysToExpiration > 30) { throw new BadRequestException('Certificate renewal is only allowed within 30 days of expiration'); } // Generate new certificate with same parameters const newCertificate = await this.generateDeviceCertificate({ deviceId: existingCert.deviceId, commonName: this.extractCommonName(existingCert.subjectDn), validityDays: 365, keySize: existingCert.keySize, algorithm: existingCert.keyAlgorithm as 'RSA' | 'ECDSA', }); this.logger.log(`Certificate ${existingCert.serialNumber} renewed as ${newCertificate.serialNumber}`); return newCertificate; } /** * Gets certificates expiring soon */ async getCertificatesExpiringSoon(days: number = 30): Promise { const expirationDate = new Date(); expirationDate.setDate(expirationDate.getDate() + days); return this.certificateRepository.find({ where: { status: CertificateStatus.ACTIVE, expiresAt: LessThan(expirationDate), }, relations: ['device'], }); } /** * Gets certificate information */ async getCertificateInfo(certificatePem: string): Promise { try { const cert = forge.pki.certificateFromPem(certificatePem); return { serialNumber: cert.serialNumber, fingerprint: this.calculateCertificateFingerprint(certificatePem), subjectDn: this.formatDistinguishedName(cert.subject.attributes), issuerDn: this.formatDistinguishedName(cert.issuer.attributes), validFrom: cert.validity.notBefore, validTo: cert.validity.notAfter, keyAlgorithm: this.getKeyAlgorithm(cert.publicKey), keySize: this.getKeySize(cert.publicKey), signatureAlgorithm: cert.signatureOid, }; } catch (error) { throw new BadRequestException('Invalid certificate format'); } } /** * Updates certificate usage statistics */ async recordCertificateUsage(serialNumber: string): Promise { await this.certificateRepository.increment( { serialNumber }, 'usageCount', 1, ); await this.certificateRepository.update( { serialNumber }, { lastUsedAt: new Date() }, ); } /** * Initializes CA certificate and key */ private initializeCa(): void { try { const caCertPem = this.configService.get('DEVICE_CA_CERT'); const caKeyPem = this.configService.get('DEVICE_CA_KEY'); if (caCertPem && caKeyPem) { this.caCertificate = forge.pki.certificateFromPem(caCertPem); this.caPrivateKey = forge.pki.privateKeyFromPem(caKeyPem); this.logger.log('CA certificate and key loaded successfully'); } else { this.logger.warn('CA certificate and key not configured, using self-signed certificates'); } } catch (error) { this.logger.error('Failed to load CA certificate and key:', error); } } /** * Generates a key pair */ private generateKeyPair(algorithm: string, keySize: number): forge.pki.KeyPair { if (algorithm === 'RSA') { return forge.pki.rsa.generateKeyPair({ bits: keySize, workers: -1, // Use available web workers }); } else if (algorithm === 'ECDSA') { // Note: node-forge has limited ECDSA support throw new BadRequestException('ECDSA key generation not supported in current implementation'); } else { throw new BadRequestException(`Unsupported algorithm: ${algorithm}`); } } /** * Generates a unique serial number */ private generateSerialNumber(): string { return crypto.randomBytes(16).toString('hex'); } /** * Calculates certificate fingerprint (SHA-256) */ private calculateCertificateFingerprint(certificatePem: string): string { return crypto.createHash('sha256').update(certificatePem).digest('hex'); } /** * Formats distinguished name */ private formatDistinguishedName(attributes: any[]): string { return attributes .map(attr => `${attr.name}=${attr.value}`) .join(', '); } /** * Extracts common name from DN */ private extractCommonName(dn: string): string { const match = dn.match(/commonName=([^,]+)/); return match ? match[1] : 'Unknown'; } /** * Gets key algorithm from public key */ private getKeyAlgorithm(publicKey: forge.pki.PublicKey): string { // In node-forge, RSA keys have 'n' property return (publicKey as any).n ? 'RSA' : 'Unknown'; } /** * Gets key size from public key */ private getKeySize(publicKey: forge.pki.PublicKey): number { const rsaKey = publicKey as any; return rsaKey.n ? rsaKey.n.bitLength() : 0; } }