File

src/crypto/crypto.service.ts

Description

Service for cryptographic operations, including key management and certificate handling.

Index

Properties
Methods

Constructor

constructor(configService: ConfigService, keyService: KeyService, certRepository: Repository<CertEntity>, logger: PinoLogger, tenantRepository: Repository<TenantEntity>, configImportService: ConfigImportService)

Constructor for CryptoService.

Parameters :
Name Type Optional
configService ConfigService No
keyService KeyService No
certRepository Repository<CertEntity> No
logger PinoLogger No
tenantRepository Repository<TenantEntity> No
configImportService ConfigImportService No

Methods

deleteKey
deleteKey(tenantId: string, id: string)

Delete a key from the key service and the cert.

Parameters :
Name Type Optional
tenantId string No
id string No
Returns : void
fetchFunction
fetchFunction(tenantId: string)

Override the fetch function since key can be passed

Parameters :
Name Type Optional
tenantId string No
Returns : () => Promise<Response>
getCallbackContext
getCallbackContext(tenantId: string)

Get the callback context for the key service.

Parameters :
Name Type Optional
tenantId string No
Returns : Omit<CallbackContext, "encryptJwe" | "decryptJwe">
getCert
getCert(tenantId: string, keyId: string)

Get the certificate for the given tenant and keyId.

Parameters :
Name Type Optional
tenantId string No
keyId string No
Returns : Promise<string>
Async getCertChain
getCertChain(type: CertificateType, tenantId: string, keyId?: string)

Get the certificate chain for the given type to be included in the JWS header.

Parameters :
Name Type Optional Default value
type CertificateType No "signing"
tenantId string No
keyId string Yes
Returns : unknown
getCertEntry
getCertEntry(tenantId: string, keyId: string)

Get a certificate entry by tenantId and keyId.

Parameters :
Name Type Optional
tenantId string No
keyId string No
getCerts
getCerts(tenantId: string)

Imports keys and certificates from the configured folder.

Parameters :
Name Type Optional
tenantId string No
getJwks
getJwks(tenantId: string)

Get the JWKs for the tenant.

Parameters :
Name Type Optional
tenantId string No
Returns : Promise<EC_Public>
getSignJwtCallback
getSignJwtCallback(tenantId: string)
Parameters :
Name Type Optional
tenantId string No
Returns : SignJwtCallback
Async hasCerts
hasCerts(tenant: TenantEntity, id?: string)

Ensures a signing certificate (and default access cert) exist for the given tenant/key id.

Parameters :
Name Type Optional
tenant TenantEntity No
id string Yes
Returns : any
hasEntry
hasEntry(tenantId: string, keyId: string)

Check if a certificate exists for the given tenant and keyId.

Parameters :
Name Type Optional
tenantId string No
keyId string No
Returns : Promise<boolean>
Async import
import()

Imports keys from the file system into the key service.

Returns : any
Async importKey
importKey(tenant: TenantEntity, body: KeyImportDto)

Imports a key into the key service.

Parameters :
Name Type Optional
tenant TenantEntity No
body KeyImportDto No
Returns : Promise<string>
Async onTenantInit
onTenantInit(tenant: TenantEntity)

Initializes the key service for a specific tenant.

Parameters :
Name Type Optional
tenant TenantEntity No
Returns : any
signJwt
signJwt(header: any, payload: any, tenantId: string, keyId?: string)

Sign a JWT with the key service.

Parameters :
Name Type Optional
header any No
payload any No
tenantId string No
keyId string Yes
Returns : Promise<string>
Async storeAccessCertificate
storeAccessCertificate(crt: string, tenantId: string, id: string)

Store the access certificate for the tenant.

Parameters :
Name Type Optional
crt string No
tenantId string No
id string No
Returns : any
updateCert
updateCert(tenantId: string, id: string, body: UpdateKeyDto)

Update an existing certificate in the key service.

Parameters :
Name Type Optional
tenantId string No
id string No
body UpdateKeyDto No
Returns : void
Async verifyJwt
verifyJwt(compact: string, tenantId: string, payload?: Record)

Verify a JWT with the key service.

Parameters :
Name Type Optional
compact string No
tenantId string No
payload Record<string | any> Yes
Returns : Promise<literal type>

Properties

folder
Type : string

Folder where the keys are stored.

Public Readonly keyService
Type : KeyService
Decorators :
@Inject('KeyService')
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { URL } from "node:url";
import { Inject, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectRepository } from "@nestjs/typeorm";
import {
    type CallbackContext,
    calculateJwkThumbprint,
    clientAuthenticationNone,
    HashAlgorithm,
    type Jwk,
    SignJwtCallback,
} from "@openid4vc/oauth2";
import * as x509 from "@peculiar/x509";
import { plainToClass } from "class-transformer";
import { importJWK, type JWK, jwtVerify } from "jose";
import { PinoLogger } from "nestjs-pino";
import { Repository } from "typeorm";
import { TenantEntity } from "../auth/tenant/entitites/tenant.entity";
import { ConfigImportService } from "../utils/config-import/config-import.service";
import { EC_Public } from "../well-known/dto/jwks-response.dto";
import { KeyImportDto } from "./key/dto/key-import.dto";
import { UpdateKeyDto } from "./key/dto/key-update.dto";
import { CertEntity, CertificateType } from "./key/entities/cert.entity";
import { KeyService } from "./key/key.service";

const ECDSA_P256 = {
    name: "ECDSA",
    namedCurve: "P-256",
    hash: "SHA-256" as const,
};

/**
 * Service for cryptographic operations, including key management and certificate handling.
 */
@Injectable()
export class CryptoService {
    /**
     * Folder where the keys are stored.
     */
    folder!: string;

    /**
     * Constructor for CryptoService.
     * @param configService
     * @param keyService
     * @param certRepository
     */
    constructor(
        private readonly configService: ConfigService,
        @Inject("KeyService") public readonly keyService: KeyService,
        @InjectRepository(CertEntity)
        private certRepository: Repository<CertEntity>,
        private logger: PinoLogger,
        @InjectRepository(TenantEntity)
        private tenantRepository: Repository<TenantEntity>,
        private configImportService: ConfigImportService,
    ) {}

    /**
     * Initializes the key service for a specific tenant.
     * @param tenantId
     */
    async onTenantInit(tenant: TenantEntity) {
        const keyId = await this.keyService.init(tenant.id);
        await this.hasCerts(tenant, keyId);
    }

    /**
     * Imports keys and certificates from the configured folder.
     * @param tenantId
     * @returns
     */
    getCerts(tenantId: string): Promise<CertEntity[]> {
        return this.certRepository.findBy({
            tenantId,
        });
    }

    /**
     * Imports keys from the file system into the key service.
     */
    async import() {
        await this.configImportService.importConfigs<KeyImportDto>({
            subfolder: "keys",
            fileExtension: ".json",
            validationClass: KeyImportDto,
            resourceType: "key",
            loadData: (filePath) => {
                const payload = JSON.parse(readFileSync(filePath, "utf8"));
                return plainToClass(KeyImportDto, payload);
            },
            checkExists: (tenantId, data) => {
                const id = data.privateKey.kid;
                return this.keyService
                    .getPublicKey("jwk", tenantId, id)
                    .then(() => true)
                    .catch(() => false);
            },
            deleteExisting: async (tenantId, data) => {
                const id = data.privateKey.kid;
                await this.certRepository.delete({ id, tenantId });
            },
            processItem: async (tenantId, config) => {
                const tenantEntity =
                    await this.tenantRepository.findOneByOrFail({
                        id: tenantId,
                    });
                await this.importKey(tenantEntity, config).catch((err) => {
                    this.logger.info(err.message);
                });
            },
        });
    }

    /**
     * Imports a key into the key service.
     * @param tenant
     * @param body
     * @returns
     */
    async importKey(tenant: TenantEntity, body: KeyImportDto): Promise<string> {
        const id = await this.keyService.import(tenant.id, body);
        // If the private key has a certificate, write it to the certs folder
        if (body.crt) {
            await this.certRepository.save({
                tenantId: tenant.id,
                id,
                crt: body.crt,
                description: body.description,
            });
        } else {
            // If no certificate is provided, generate a self-signed certificate
            await this.hasCerts(tenant, id);
            if (body.description) {
                await this.certRepository.update(
                    { tenantId: tenant.id, id },
                    { description: body.description },
                );
            }
        }
        return id;
    }

    /**
     * Ensures a signing certificate (and default access cert) exist for the given tenant/key id.
     */
    async hasCerts(tenant: TenantEntity, id?: string) {
        id = id ?? (await this.keyService.getKid(tenant.id));

        const existing = await this.certRepository.findOneBy({
            tenantId: tenant.id,
            id,
        });
        if (existing?.crt) return;
        //TODO: load CN from other source, e.g. config. Also required for access certificate
        // === Inputs/parameters (subject + SAN hostname) ===
        const subjectCN = tenant.name;
        const hostname = new URL(
            this.configService.getOrThrow<string>("PUBLIC_URL"),
        ).hostname;

        // === Parse the subject public key we want the leaf cert to contain ===
        // Expecting PEM SPKI. If you have JWK, convert or import as CryptoKey first.
        const subjectSpkiPem = await this.keyService.getPublicKey(
            "pem",
            tenant.id,
            id,
        );
        const subjectPublicKey = await new x509.PublicKey(
            subjectSpkiPem,
        ).export({ name: "ECDSA", namedCurve: "P-256" }, ["verify"]);

        // === Create issuer key pair and self-signed issuer certificate ===
        const issuerKeys = await crypto.subtle.generateKey(ECDSA_P256, true, [
            "sign",
            "verify",
        ]);
        const now = new Date();
        const inOneYear = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);

        const issuerCert = await x509.X509CertificateGenerator.createSelfSigned(
            {
                serialNumber: "01",
                name: `CN=${subjectCN}`,
                notBefore: now,
                notAfter: inOneYear,
                signingAlgorithm: ECDSA_P256,
                keys: issuerKeys,
                extensions: [
                    new x509.BasicConstraintsExtension(true, 0, true), // CA: true, pathLen:0
                    new x509.KeyUsagesExtension(
                        x509.KeyUsageFlags.keyCertSign |
                            x509.KeyUsageFlags.cRLSign,
                        true,
                    ),
                    await x509.SubjectKeyIdentifierExtension.create(
                        issuerKeys.publicKey,
                    ),
                    new x509.SubjectAlternativeNameExtension([
                        { type: "dns", value: hostname },
                    ]),
                ],
            },
        );

        // === Issue end-entity certificate for the provided public key ===
        const leafCert = await x509.X509CertificateGenerator.create({
            serialNumber: "02",
            subject: `CN=${subjectCN}`,
            issuer: issuerCert.subject, // DN string from issuer
            notBefore: now,
            notAfter: inOneYear,
            signingAlgorithm: ECDSA_P256,
            publicKey: subjectPublicKey, // <-- your key goes into the cert
            signingKey: issuerKeys.privateKey, // signed by issuer
            extensions: [
                new x509.SubjectAlternativeNameExtension([
                    { type: "dns", value: hostname },
                ]),
                new x509.KeyUsagesExtension(
                    x509.KeyUsageFlags.digitalSignature,
                    false,
                ),
                await x509.SubjectKeyIdentifierExtension.create(
                    subjectPublicKey,
                ),
                await x509.AuthorityKeyIdentifierExtension.create(
                    issuerCert.publicKey,
                ),
            ],
        });

        const crtPem = leafCert.toString("pem"); // PEM-encoded certificate

        // Persist the signing certificate
        await this.certRepository.save({
            tenantId: tenant.id,
            id,
            crt: crtPem,
            type: "signing",
        });

        // Mirror your logic: if no "access" cert yet, reuse the same PEM
        const accessCount = await this.certRepository.countBy({
            tenantId: tenant.id,
            type: "access",
        });
        if (accessCount === 0) {
            await this.certRepository.save({
                tenantId: tenant.id,
                id,
                crt: crtPem,
                type: "access",
            });
        }
    }

    /**
     * Check if a certificate exists for the given tenant and keyId.
     * @param tenantId
     * @param keyId
     * @returns
     */
    hasEntry(tenantId: string, keyId: string): Promise<boolean> {
        return this.certRepository
            .findOneBy({ tenantId, id: keyId })
            .then((cert) => !!cert);
    }

    /**
     * Get a certificate entry by tenantId and keyId.
     * @param tenantId
     * @param keyId
     * @returns
     */
    getCertEntry(tenantId: string, keyId: string): Promise<CertEntity> {
        return this.certRepository.findOneByOrFail({ tenantId, id: keyId });
    }

    /**
     * Get the certificate for the given tenant and keyId.
     * @param tenantId
     * @param keyId
     * @returns
     */
    getCert(tenantId: string, keyId: string): Promise<string> {
        return this.certRepository
            .findOneBy({ tenantId, id: keyId })
            .then((cert) => cert!.crt);
    }

    /**
     * Update an existing certificate in the key service.
     * @param tenantId
     * @param id
     * @param body
     */
    updateCert(tenantId: string, id: string, body: UpdateKeyDto) {
        this.certRepository.update({ tenantId, id }, body);
    }

    /**
     * Get the certificate chain for the given type to be included in the JWS header.
     * @param type
     * @param tenantId
     * @param keyId
     * @returns
     */
    async getCertChain(
        type: CertificateType = "signing",
        tenantId: string,
        keyId?: string,
    ) {
        let cert: string;
        if (type === "signing") {
            keyId = keyId || (await this.keyService.getKid(tenantId));
            cert = await this.getCert(tenantId, keyId);
        } else {
            cert = await this.certRepository
                .findOneByOrFail({
                    tenantId,
                    type: "access",
                })
                .then((cert) => cert.crt);
        }

        const chain = cert
            .replace("-----BEGIN CERTIFICATE-----", "")
            .replace("-----END CERTIFICATE-----", "")
            .replace(/\r?\n|\r/g, "");
        return [chain];
    }

    /**
     * Store the access certificate for the tenant.
     * @param crt
     * @param tenantId
     */
    async storeAccessCertificate(crt: string, tenantId: string, id: string) {
        await this.certRepository.save({
            tenantId,
            id,
            crt,
            type: "access",
        });
    }

    /**
     * Sign a JWT with the key service.
     * @param header
     * @param payload
     * @param tenantId
     * @returns
     */
    signJwt(
        header: any,
        payload: any,
        tenantId: string,
        keyId?: string,
    ): Promise<string> {
        return this.keyService.signJWT(payload, header, tenantId, keyId);
    }

    /**
     * Verify a JWT with the key service.
     * @param compact
     * @param tenantId
     * @param payload
     * @returns
     */
    async verifyJwt(
        compact: string,
        tenantId: string,
        payload?: Record<string, any>,
    ): Promise<{ verified: boolean }> {
        const publicJwk = await this.keyService.getPublicKey("jwk", tenantId);
        const publicCryptoKey = await importJWK(publicJwk, "ES256");

        try {
            await jwtVerify(compact, publicCryptoKey, {
                currentDate: payload?.exp
                    ? new Date((payload.exp - 300) * 1000)
                    : undefined,
            });
            return { verified: true };
        } catch {
            return { verified: false };
        }
    }
    /**
     * Get the callback context for the key service.
     * @param tenantId
     * @returns
     */
    getCallbackContext(
        tenantId: string,
    ): Omit<CallbackContext, "encryptJwe" | "decryptJwe"> {
        return {
            hash: (data, alg) =>
                createHash(alg.replace("-", "").toLowerCase())
                    .update(data)
                    .digest(),
            generateRandom: (bytes) => randomBytes(bytes),
            clientAuthentication: clientAuthenticationNone({
                clientId: "some-random",
            }),
            fetch: this.fetchFunction(tenantId).bind(this),
            //clientId: 'some-random-client-id', // TODO: Replace with your real clientId if necessary
            signJwt: this.getSignJwtCallback(tenantId),
            verifyJwt: async (signer, { compact, payload }) => {
                if (signer.method !== "jwk") {
                    throw new Error("Signer method not supported");
                }

                const josePublicKey = await importJWK(
                    signer.publicJwk as JWK,
                    signer.alg,
                );
                try {
                    await jwtVerify(compact, josePublicKey, {
                        currentDate: payload?.exp
                            ? new Date((payload.exp - 300) * 1000)
                            : undefined,
                    });
                    return { verified: true, signerJwk: signer.publicJwk };
                } catch {
                    return { verified: false };
                }
            },
        };
    }

    /**
     * Override the fetch function since key can be passed
     * @param tenantId
     * @returns
     */
    fetchFunction(tenantId: string) {
        return (): Promise<Response> => {
            return Promise.resolve({
                json: async () => {
                    return {
                        keys: [await this.getJwks(tenantId)],
                    };
                },
                ok: true,
                headers: new Headers({ "Content-Type": "application/json" }),
            }) as Promise<Response>;
        };
    }

    // Helper to generate signJwt callback
    getSignJwtCallback(tenantId: string): SignJwtCallback {
        return async (signer, { header, payload }) => {
            if (signer.method !== "jwk") {
                throw new Error("Signer method not supported");
            }
            const hashCallback = this.getCallbackContext(tenantId).hash;
            const jwkThumbprint = await calculateJwkThumbprint({
                jwk: signer.publicJwk,
                hashAlgorithm: HashAlgorithm.Sha256,
                hashCallback,
            });

            const privateThumbprint = await calculateJwkThumbprint({
                jwk: (await this.keyService.getPublicKey(
                    "jwk",
                    tenantId,
                )) as Jwk,
                hashAlgorithm: HashAlgorithm.Sha256,
                hashCallback,
            });

            if (jwkThumbprint !== privateThumbprint) {
                throw new Error(
                    `No private key available for public jwk \n${JSON.stringify(signer.publicJwk, null, 2)}`,
                );
            }

            const jwt = await this.signJwt(header, payload, tenantId);

            return {
                jwt,
                signerJwk: signer.publicJwk,
            };
        };
    }

    /**
     * Get the JWKs for the tenant.
     * @param tenantId
     * @returns
     */
    getJwks(tenantId: string) {
        return this.keyService.getPublicKey(
            "jwk",
            tenantId,
        ) as Promise<EC_Public>;
    }

    /**
     * Delete a key from the key service and the cert.
     * @param tenantId
     * @param id
     */
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    deleteKey(tenantId: string, id: string) {
        //TODO: before deleting it, make sure it is not used in a configuration
        throw new Error("Method not implemented.");
    }
}

results matching ""

    No results matching ""