- Add hardware fingerprinting with cross-platform support - Implement secure device registration flow with X.509 certificates - Add WebSocket real-time communication for device status - Create comprehensive device management dashboard - Establish zero-trust security architecture with multi-layer protection - Add database migrations for device registration entities - Implement Rust edge client with hardware identification - Add certificate management and automated provisioning system 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
467 lines
14 KiB
TypeScript
467 lines
14 KiB
TypeScript
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<DeviceCertificate>,
|
|
@InjectRepository(Device)
|
|
private deviceRepository: Repository<Device>,
|
|
private configService: ConfigService,
|
|
) {
|
|
this.initializeCa();
|
|
}
|
|
|
|
/**
|
|
* Generates a new device certificate
|
|
*/
|
|
async generateDeviceCertificate(options: CertificateGenerationOptions): Promise<DeviceCertificate> {
|
|
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<CertificateValidationResult> {
|
|
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<void> {
|
|
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<DeviceCertificate> {
|
|
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<DeviceCertificate[]> {
|
|
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<CertificateInfo> {
|
|
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<void> {
|
|
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<string>('DEVICE_CA_CERT');
|
|
const caKeyPem = this.configService.get<string>('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;
|
|
}
|
|
} |