merge(09-01): resolve app.module.ts conflict (CertManagerModule + FavoritesModule)
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
"start": "node dist/main.js",
|
||||
"start:dev": "nest start --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"postinstall": "test -f prisma/schema.prisma && prisma generate || true"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -21,7 +23,6 @@
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@prisma/client": "^6.0.0",
|
||||
"prisma": "^6.0.0",
|
||||
"@tessera/shared": "workspace:*",
|
||||
"argon2": "^0.44.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -32,12 +33,14 @@
|
||||
"httpntlm": "^1.8.13",
|
||||
"imapflow": "^1.4.3",
|
||||
"ldapts": "^8.1.8",
|
||||
"node-forge": "^1.4.0",
|
||||
"node-ical": "0.26.1",
|
||||
"nodemailer": "^9.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"prisma": "^6.0.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.0.0",
|
||||
"tsdav": "2.2.2",
|
||||
@@ -48,9 +51,11 @@
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"typescript": "^5.5.0"
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MailModule } from './mail/mail.module';
|
||||
import { CalendarModule } from './calendar/calendar.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { DkvModule } from './dkv/dkv.module';
|
||||
import { CertManagerModule } from './cert-manager/cert-manager.module';
|
||||
import { FavoritesModule } from './favorites/favorites.module';
|
||||
import { DomaincheckModule } from './domaincheck/domaincheck.module';
|
||||
import { ModuleRegistryModule } from './module-registry/module-registry.module';
|
||||
@@ -34,6 +35,7 @@ import { UserModule } from './user/user.module';
|
||||
LdapModule,
|
||||
ModuleRegistryModule,
|
||||
DomaincheckModule,
|
||||
CertManagerModule,
|
||||
DashboardModule,
|
||||
CalendarModule,
|
||||
SettingsModule,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { UseModule } from '../module-registry/module.guard';
|
||||
import { CertManagerService } from './cert-manager.service';
|
||||
|
||||
/**
|
||||
* CertManagerController — 4 POST endpoints for certificate operations.
|
||||
*
|
||||
* All routes are protected by:
|
||||
* - Global JwtAuthGuard (authentication)
|
||||
* - Global TenantGuard (tenant context)
|
||||
* - @UseModule('cert-manager') ModuleGuard (module activation check)
|
||||
*
|
||||
* File size limit: 5 MB per file (T-09-03 — DoS mitigation).
|
||||
* Password parameter is never passed to a logger (T-09-02 — InfoDisc mitigation).
|
||||
*/
|
||||
@Controller('modules/cert-manager')
|
||||
@UseModule('cert-manager')
|
||||
export class CertManagerController {
|
||||
constructor(private readonly certManagerService: CertManagerService) {}
|
||||
|
||||
/**
|
||||
* POST /modules/cert-manager/parse
|
||||
* Inspect a single certificate: subject, issuer, validity, SANs, fingerprints.
|
||||
* Accepts multipart file upload OR JSON body with pemText.
|
||||
*/
|
||||
@Post('parse')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
async parseCert(
|
||||
@UploadedFile() file: any,
|
||||
@Body('password') password?: string,
|
||||
@Body('pemText') pemText?: string,
|
||||
) {
|
||||
if (!file && !pemText) {
|
||||
throw new BadRequestException('No file or PEM text provided');
|
||||
}
|
||||
return this.certManagerService.parseCert({ file, pemText, password });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /modules/cert-manager/split
|
||||
* Split a fullchain.pem or P7B bundle into individual certificates.
|
||||
*/
|
||||
@Post('split')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
async splitCerts(
|
||||
@UploadedFile() file: any,
|
||||
@Body('password') password?: string,
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
return this.certManagerService.splitCerts({ file, password });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /modules/cert-manager/merge
|
||||
* Merge multiple certificates into a PEM chain or PFX bundle.
|
||||
* Uses FilesInterceptor (plural) to accept multiple files with field name "files".
|
||||
*/
|
||||
@Post('merge')
|
||||
@UseInterceptors(
|
||||
FilesInterceptor('files', 20, {
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
async mergeCerts(
|
||||
@UploadedFiles() files: any[],
|
||||
@Body('outputFormat') outputFormat: string,
|
||||
@Body('password') password?: string,
|
||||
) {
|
||||
if (!files || files.length < 2) {
|
||||
throw new BadRequestException('At least 2 files required for merge');
|
||||
}
|
||||
return this.certManagerService.mergeCerts({ files, outputFormat, password });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /modules/cert-manager/convert
|
||||
* Convert a certificate between PEM, DER, PFX/P12, P7B, CRT/CER formats.
|
||||
*/
|
||||
@Post('convert')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
async convertCert(
|
||||
@UploadedFile() file: any,
|
||||
@Body('targetFormat') targetFormat: string,
|
||||
@Body('password') password?: string,
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
return this.certManagerService.convertCert({ file, targetFormat, password });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
import { CertManagerController } from './cert-manager.controller';
|
||||
import { seedCertManagerModule } from './cert-manager.seed';
|
||||
import { CertManagerService } from './cert-manager.service';
|
||||
|
||||
/**
|
||||
* NestJS module for the Cert Manager feature.
|
||||
*
|
||||
* Provides server-side certificate inspection, splitting, merging,
|
||||
* and format conversion using node-forge (pure JS, no native bindings).
|
||||
*
|
||||
* Seeds itself into the module registry on application startup via
|
||||
* OnModuleInit lifecycle hook (CERT-06).
|
||||
*
|
||||
* After seeding: an admin must activate the module per-tenant via the
|
||||
* Marketplace UI before endpoints become accessible (ModuleGuard checks
|
||||
* TenantModuleActivation, not isSystem flag — RESEARCH.md Pitfall 2).
|
||||
*/
|
||||
@Module({
|
||||
imports: [ModuleRegistryModule],
|
||||
controllers: [CertManagerController],
|
||||
providers: [CertManagerService],
|
||||
})
|
||||
export class CertManagerModule implements OnModuleInit {
|
||||
private readonly logger = new Logger(CertManagerModule.name);
|
||||
|
||||
constructor(
|
||||
private readonly moduleRegistryService: ModuleRegistryService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await seedCertManagerModule(this.moduleRegistryService);
|
||||
this.logger.log('Cert-Manager module seeded in registry');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to seed cert-manager module', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
|
||||
/**
|
||||
* Seeds the cert-manager module into the module registry.
|
||||
*
|
||||
* Called during CertManagerModule initialization to ensure the
|
||||
* "cert-manager" module record exists in the database (CERT-06).
|
||||
*
|
||||
* isSystem: true registers the module in the registry but does NOT
|
||||
* auto-activate it per tenant. Admin must activate via Marketplace UI.
|
||||
*/
|
||||
export async function seedCertManagerModule(
|
||||
moduleRegistryService: ModuleRegistryService,
|
||||
): Promise<void> {
|
||||
await moduleRegistryService.seedModule({
|
||||
slug: 'cert-manager',
|
||||
name: 'Cert Manager',
|
||||
version: '1.0.0',
|
||||
category: 'security-tools',
|
||||
description: {
|
||||
de: 'Zertifikate analysieren, konvertieren und verwalten',
|
||||
en: 'Inspect, convert and manage certificates',
|
||||
},
|
||||
isSystem: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import * as forge from 'node-forge';
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { seedCertManagerModule } from './cert-manager.seed';
|
||||
import { CertManagerService } from './cert-manager.service';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Generate a self-signed X.509 cert via node-forge (RSA 1024 — fast for tests) */
|
||||
function generateSelfSignedCert(): forge.pki.Certificate {
|
||||
const keys = forge.pki.rsa.generateKeyPair(1024);
|
||||
const cert = forge.pki.createCertificate();
|
||||
cert.publicKey = keys.publicKey;
|
||||
cert.serialNumber = '01';
|
||||
cert.validity.notBefore = new Date();
|
||||
cert.validity.notAfter = new Date();
|
||||
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
|
||||
|
||||
const attrs = [{ name: 'commonName', value: 'test.example.com' }];
|
||||
cert.setSubject(attrs);
|
||||
cert.setIssuer(attrs);
|
||||
cert.sign(keys.privateKey, forge.md.sha256.create());
|
||||
return cert;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('seedCertManagerModule', () => {
|
||||
it('calls moduleRegistryService.seedModule once with cert-manager slug', async () => {
|
||||
const mockSeedModule = vi.fn().mockResolvedValue(undefined);
|
||||
const mockModuleRegistryService = { seedModule: mockSeedModule } as any;
|
||||
|
||||
await seedCertManagerModule(mockModuleRegistryService);
|
||||
|
||||
expect(mockSeedModule).toHaveBeenCalledTimes(1);
|
||||
const arg = mockSeedModule.mock.calls[0][0];
|
||||
expect(arg.slug).toBe('cert-manager');
|
||||
expect(arg.category).toBe('security-tools');
|
||||
expect(arg.isSystem).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CertManagerService helpers', () => {
|
||||
let service: CertManagerService;
|
||||
let testCert: forge.pki.Certificate;
|
||||
|
||||
beforeAll(() => {
|
||||
service = new CertManagerService();
|
||||
testCert = generateSelfSignedCert();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// detectFormat
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('detectFormat', () => {
|
||||
it('returns "pfx" for .pfx extension', () => {
|
||||
const buf = Buffer.from([0x30, 0x82]); // arbitrary binary
|
||||
expect(service.detectFormat('cert.pfx', buf)).toBe('pfx');
|
||||
});
|
||||
|
||||
it('returns "p7b" for .p7b extension', () => {
|
||||
const buf = Buffer.from([0x30, 0x82]);
|
||||
expect(service.detectFormat('cert.p7b', buf)).toBe('p7b');
|
||||
});
|
||||
|
||||
it('returns "der" for .der extension', () => {
|
||||
const buf = Buffer.from([0x30, 0x82]);
|
||||
expect(service.detectFormat('cert.der', buf)).toBe('der');
|
||||
});
|
||||
|
||||
it('returns "pem" for .pem extension', () => {
|
||||
const buf = Buffer.from('-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----');
|
||||
expect(service.detectFormat('cert.pem', buf)).toBe('pem');
|
||||
});
|
||||
|
||||
it('returns "pem" for .cer extension with PEM content', () => {
|
||||
const buf = Buffer.from('-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----');
|
||||
expect(service.detectFormat('cert.cer', buf)).toBe('pem');
|
||||
});
|
||||
|
||||
it('returns "der" for .cer extension with binary (non-PEM) content', () => {
|
||||
const buf = Buffer.from([0x30, 0x82, 0x01, 0x00]);
|
||||
expect(service.detectFormat('cert.cer', buf)).toBe('der');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// getFingerprint
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('getFingerprint', () => {
|
||||
it('returns uppercase colon-separated hex for sha256', () => {
|
||||
const fp = service.getFingerprint(testCert, 'sha256');
|
||||
// e.g. "AA:BB:CC:..."
|
||||
expect(fp).toMatch(/^[0-9A-F]{2}(:[0-9A-F]{2})+$/);
|
||||
});
|
||||
|
||||
it('returns uppercase colon-separated hex for sha1', () => {
|
||||
const fp = service.getFingerprint(testCert, 'sha1');
|
||||
expect(fp).toMatch(/^[0-9A-F]{2}(:[0-9A-F]{2})+$/);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// parsePemChain
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('parsePemChain', () => {
|
||||
it('returns array of length 2 for two concatenated cert PEMs', () => {
|
||||
const cert1 = generateSelfSignedCert();
|
||||
const cert2 = generateSelfSignedCert();
|
||||
const pem1 = forge.pki.certificateToPem(cert1);
|
||||
const pem2 = forge.pki.certificateToPem(cert2);
|
||||
const chain = pem1 + '\n' + pem2;
|
||||
|
||||
const parsed = service.parsePemChain(chain);
|
||||
expect(parsed).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns array of length 1 for a single PEM', () => {
|
||||
const pem = forge.pki.certificateToPem(testCert);
|
||||
const parsed = service.parsePemChain(pem);
|
||||
expect(parsed).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { BadRequestException, Injectable, Logger, NotImplementedException } from '@nestjs/common';
|
||||
import * as forge from 'node-forge';
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation method stubs (implemented in later plan slices)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async parseCert(_input: {
|
||||
file?: any;
|
||||
pemText?: string;
|
||||
password?: string;
|
||||
}): Promise<never> {
|
||||
throw new NotImplementedException('parseCert is not yet implemented');
|
||||
}
|
||||
|
||||
async splitCerts(_input: {
|
||||
file?: any;
|
||||
password?: string;
|
||||
}): Promise<never> {
|
||||
throw new NotImplementedException('splitCerts is not yet implemented');
|
||||
}
|
||||
|
||||
async mergeCerts(_input: {
|
||||
files?: any[];
|
||||
outputFormat: string;
|
||||
password?: string;
|
||||
}): Promise<never> {
|
||||
throw new NotImplementedException('mergeCerts is not yet implemented');
|
||||
}
|
||||
|
||||
async convertCert(_input: {
|
||||
file?: any;
|
||||
targetFormat: string;
|
||||
password?: string;
|
||||
}): Promise<never> {
|
||||
throw new NotImplementedException('convertCert is not yet implemented');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* DTO for the cert-manager convert endpoint body fields.
|
||||
* Used alongside FileInterceptor for single-file upload.
|
||||
*/
|
||||
export class ConvertCertDto {
|
||||
targetFormat!: 'pem' | 'der' | 'pfx' | 'p7b';
|
||||
password?: string;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* DTO for the cert-manager merge endpoint body fields.
|
||||
* Used alongside FilesInterceptor for multi-file upload.
|
||||
*/
|
||||
export class MergeCertsDto {
|
||||
outputFormat!: 'pem' | 'pfx';
|
||||
password?: string;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* DTO for the cert-manager parse endpoint (text paste / JSON body path).
|
||||
* For file uploads the body fields are extracted via @Body() in the controller.
|
||||
*/
|
||||
export class ParseCertDto {
|
||||
pemText!: string;
|
||||
password?: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
include: ['src/**/*.spec.ts'],
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user