File

src/crypto/key/key-chain-import.service.ts

Description

Handles config-driven key-chain import and the config-import lifecycle hook.

Keeps import logic isolated from the CRUD and signing concerns in KeyChainService and KeyChainSigningService.

Index

Properties
Methods

Constructor

constructor(keyChainRepository: Repository<KeyChainEntity>, tenantRepository: Repository<TenantEntity>, configService: ConfigService, configImportService: ConfigImportService, kmsRegistry: KmsProviderRegistry, certBuilder: CertificateBuilderService, configImportOrchestrator: ConfigImportOrchestratorService)
Parameters :
Name Type Optional
keyChainRepository Repository<KeyChainEntity> No
tenantRepository Repository<TenantEntity> No
configService ConfigService No
configImportService ConfigImportService No
kmsRegistry KmsProviderRegistry No
certBuilder CertificateBuilderService No
configImportOrchestrator ConfigImportOrchestratorService No

Methods

Private getHostname
getHostname()
Returns : string
Async importForTenant
importForTenant(tenantId: string)
Parameters :
Name Type Optional
tenantId string No
Returns : Promise<void>
Async importKeyChain
importKeyChain(tenantId: string, dto: KeyChainImportDto)
Parameters :
Name Type Optional
tenantId string No
dto KeyChainImportDto No
Returns : Promise<string>
Private Async importKeyChainWithRotation
importKeyChainWithRotation(id: string, tenantId: string, subjectCN: string, hostname: string, rootKeyJwk: JWK, adapter: KmsAdapter, dto: KeyChainImportDto)
Parameters :
Name Type Optional
id string No
tenantId string No
subjectCN string No
hostname string No
rootKeyJwk JWK No
adapter KmsAdapter No
dto KeyChainImportDto No
Returns : Promise<string>
Private storedKeyForEntity
storedKeyForEntity(adapter: KmsAdapter, ref: KmsKeyRef)

What gets persisted into the entity's JWK columns. For db we keep the full private JWK; for external providers only the public JWK.

Parameters :
Name Type Optional
adapter KmsAdapter No
ref KmsKeyRef No
Returns : JWK

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(KeyChainImportService.name)
import { readFileSync } from "node:fs";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectRepository } from "@nestjs/typeorm";
import { plainToClass } from "class-transformer";
import type { JWK } from "jose";
import { Repository } from "typeorm";
import { v4 } from "uuid";
import { TenantEntity } from "../../auth/tenant/entitites/tenant.entity";
import {
    ConfigImportOrchestratorService,
    ImportPhase,
} from "../../shared/utils/config-import/config-import-orchestrator.service";
import { ConfigImportService } from "../../shared/utils/config-import/config-import.service";
import { CertificateBuilderService } from "./cert/certificate-builder.service";
import { KeyChainImportDto } from "./dto/key-chain-import.dto";
import { KeyChainEntity, KeyUsage } from "./entities/key-chain.entity";
import type { KmsAdapter, KmsKeyRef } from "./kms/kms-adapter";
import { KmsProviderRegistry } from "./kms/kms-provider.registry";

/**
 * Handles config-driven key-chain import and the config-import lifecycle hook.
 *
 * Keeps import logic isolated from the CRUD and signing concerns in
 * {@link KeyChainService} and {@link KeyChainSigningService}.
 */
@Injectable()
export class KeyChainImportService {
    private readonly logger = new Logger(KeyChainImportService.name);

    constructor(
        @InjectRepository(KeyChainEntity)
        private readonly keyChainRepository: Repository<KeyChainEntity>,
        @InjectRepository(TenantEntity)
        private readonly tenantRepository: Repository<TenantEntity>,
        private readonly configService: ConfigService,
        private readonly configImportService: ConfigImportService,
        private readonly kmsRegistry: KmsProviderRegistry,
        private readonly certBuilder: CertificateBuilderService,
        configImportOrchestrator: ConfigImportOrchestratorService,
    ) {
        configImportOrchestrator.register(
            "key-chains",
            ImportPhase.CORE,
            (tenantId) => this.importForTenant(tenantId),
        );
    }

    async importForTenant(tenantId: string): Promise<void> {
        await this.configImportService.importConfigsForTenant<KeyChainImportDto>(
            tenantId,
            {
                subfolder: "key-chains",
                fileExtension: ".json",
                validationClass: KeyChainImportDto,
                resourceType: "key-chain",
                loadData: (filePath) => {
                    const payload = JSON.parse(readFileSync(filePath, "utf8"));
                    return plainToClass(KeyChainImportDto, payload);
                },
                checkExists: async (tid, data) => {
                    return await this.keyChainRepository
                        .count({
                            where: { tenantId: tid, id: data.id },
                        })
                        .then((count) => count > 0);
                },
                processItem: async (tid, config) => {
                    await this.importKeyChain(tid, config);
                },
            },
        );
    }

    async importKeyChain(
        tenantId: string,
        dto: KeyChainImportDto,
    ): Promise<string> {
        const id = dto.id || v4();
        const tenant = await this.tenantRepository.findOneByOrFail({
            id: tenantId,
        });
        const hostname = this.getHostname();

        const privateKey: JWK = { ...dto.key };
        if (!privateKey.kid) {
            privateKey.kid = `${id}-active`;
        }
        if (!privateKey.alg) {
            privateKey.alg = "ES256";
        }

        const adapter = this.kmsRegistry.resolve(dto.kmsProvider);

        if (dto.rotationPolicy?.enabled) {
            return this.importKeyChainWithRotation(
                id,
                tenantId,
                tenant.name,
                hostname,
                privateKey,
                adapter,
                dto,
            );
        }

        const activeMat = await adapter.importKey({
            kid: privateKey.kid,
            privateJwk: privateKey,
        });

        let activeCertificate: string;
        if (dto.crt && dto.crt.length > 0) {
            activeCertificate = dto.crt.join("\n");
        } else {
            const now = new Date();
            const notAfter = new Date(
                now.getTime() + 365 * 24 * 60 * 60 * 1000,
            );
            activeCertificate = await this.certBuilder.createSelfSignedCert(
                adapter,
                activeMat.ref,
                tenant.name,
                hostname,
                now,
                notAfter,
            );
        }

        await this.keyChainRepository.save({
            id,
            tenantId,
            usageType: dto.usageType,
            usage: KeyUsage.Sign,
            description: dto.description,
            kmsProvider: adapter.providerId,
            activeJwk: this.storedKeyForEntity(adapter, activeMat.ref),
            activeCertificate,
            externalKeyId: activeMat.ref.externalKeyId,
            rotationEnabled: false,
        });

        this.logger.log(
            `Imported key chain ${id} for tenant ${tenantId} (usage: ${dto.usageType}, provider: ${adapter.providerId})`,
        );
        return id;
    }

    private async importKeyChainWithRotation(
        id: string,
        tenantId: string,
        subjectCN: string,
        hostname: string,
        rootKeyJwk: JWK,
        adapter: KmsAdapter,
        dto: KeyChainImportDto,
    ): Promise<string> {
        const now = new Date();
        const certValidityDays = dto.rotationPolicy?.certValidityDays || 365;
        const rotationIntervalDays = dto.rotationPolicy?.intervalDays || 90;
        const notAfter = new Date(
            now.getTime() + certValidityDays * 24 * 60 * 60 * 1000,
        );

        rootKeyJwk.kid = rootKeyJwk.kid || `${id}-root`;
        const rootMat = await adapter.importKey({
            kid: rootKeyJwk.kid,
            privateJwk: rootKeyJwk,
        });

        let rootCertificate: string;
        if (dto.crt && dto.crt.length > 0) {
            rootCertificate = dto.crt[0];
        } else {
            const rootNotAfter = new Date(
                now.getTime() + 10 * 365 * 24 * 60 * 60 * 1000,
            );
            rootCertificate = await this.certBuilder.createSelfSignedCaCert(
                adapter,
                rootMat.ref,
                `${subjectCN} Root CA`,
                hostname,
                now,
                rootNotAfter,
            );
        }

        const activeMat = await adapter.generateKey({
            kid: `${id}-active-${Date.now()}`,
        });
        const { chain } = await this.certBuilder.createCaSignedCert({
            caAdapter: adapter,
            caRef: rootMat.ref,
            caCertPem: rootCertificate,
            subjectPublicJwk: activeMat.ref.publicJwk,
            subjectCN,
            hostname,
            notBefore: now,
            notAfter,
        });

        await this.keyChainRepository.save({
            id,
            tenantId,
            usageType: dto.usageType,
            usage: KeyUsage.Sign,
            description: dto.description,
            kmsProvider: adapter.providerId,
            rootJwk: this.storedKeyForEntity(adapter, rootMat.ref),
            rootCertificate,
            activeJwk: this.storedKeyForEntity(adapter, activeMat.ref),
            activeCertificate: chain.join("\n"),
            externalKeyId: activeMat.ref.externalKeyId,
            rotationEnabled: true,
            rotationIntervalDays,
            certValidityDays,
        });

        this.logger.log(
            `Imported key chain ${id} with rotation for tenant ${tenantId} (usage: ${dto.usageType}, provider: ${adapter.providerId})`,
        );
        return id;
    }

    /**
     * What gets persisted into the entity's JWK columns.
     * For `db` we keep the full private JWK; for external providers only the public JWK.
     */
    private storedKeyForEntity(adapter: KmsAdapter, ref: KmsKeyRef): JWK {
        if (adapter.type === "db" && ref.privateJwk) {
            return ref.privateJwk;
        }
        return ref.publicJwk;
    }

    private getHostname(): string {
        return new URL(this.configService.getOrThrow<string>("PUBLIC_URL"))
            .hostname;
    }
}

results matching ""

    No results matching ""