File

src/crypto/key/kms/adapters/pkcs11-kms.adapter.ts

Index

Properties

Indexable

[constant: string]: unknown

Properties

PKCS11
PKCS11: unknown
Type : unknown
import { createHash } from "node:crypto";
import { Logger, NotImplementedException } from "@nestjs/common";
import { exportJWK, type JWK } from "jose";
import type { KmsProviderType } from "../../dto/kms-config.dto";
import type {
    KmsAdapter,
    KmsAdapterCapabilities,
    KmsHealthResult,
    KmsKeyMaterial,
    KmsKeyRef,
    KmsSigningAlg,
} from "../kms-adapter";
import { PublicJwkCache } from "../public-jwk-cache";

export interface Pkcs11AdapterConfig {
    providerId: string;
    /** Absolute path to the PKCS#11 module library (.so/.dll/.dylib). */
    library: string;
    /**
     * Slot selection. Either the numeric slot id, or a token label that
     * will be resolved against the slot list at initialisation time.
     */
    slot: number | string;
    /** User PIN. */
    pin: string;
    /**
     * Optional read-only mode. Defaults to false (RW session). Set to
     * true if the adapter only needs to sign with existing keys.
     */
    readOnly?: boolean;
}

// DER encoding of the P-256 named-curve OID (1.2.840.10045.3.1.7).
const P256_OID_DER = Buffer.from([
    0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07,
]);

// Minimal subset of the pkcs11js surface we touch. Avoids a hard
// compile-time dependency on the optional native module.
interface Pkcs11Module {
    PKCS11: new () => Pkcs11Instance;
    [constant: string]: unknown;
}

interface Pkcs11Instance {
    load(path: string): void;
    C_Initialize(): void;
    C_Finalize(): void;
    C_GetSlotList(tokenPresent: boolean): Buffer[];
    C_GetTokenInfo(slot: Buffer): { label: string };
    C_OpenSession(slot: Buffer, flags: number): Buffer;
    C_CloseSession(session: Buffer): void;
    C_Login(session: Buffer, userType: number, pin: string): void;
    C_Logout(session: Buffer): void;
    C_GenerateKeyPair(
        session: Buffer,
        mechanism: { mechanism: number },
        publicTemplate: Pkcs11Attribute[],
        privateTemplate: Pkcs11Attribute[],
    ): { publicKey: Buffer; privateKey: Buffer };
    C_FindObjectsInit(session: Buffer, template: Pkcs11Attribute[]): void;
    C_FindObjects(session: Buffer): Buffer | null;
    C_FindObjectsFinal(session: Buffer): void;
    C_GetAttributeValue(
        session: Buffer,
        object: Buffer,
        template: Pkcs11Attribute[],
    ): Pkcs11Attribute[];
    C_SignInit(
        session: Buffer,
        mechanism: { mechanism: number },
        key: Buffer,
    ): void;
    C_Sign(session: Buffer, data: Buffer, output: Buffer): Buffer;
    C_DestroyObject(session: Buffer, object: Buffer): void;
}

interface Pkcs11Attribute {
    type: number;
    value: unknown;
}

interface Pkcs11Constants {
    CKF_RW_SESSION: number;
    CKF_SERIAL_SESSION: number;
    CKU_USER: number;
    CKA_CLASS: number;
    CKA_KEY_TYPE: number;
    CKA_LABEL: number;
    CKA_ID: number;
    CKA_TOKEN: number;
    CKA_PRIVATE: number;
    CKA_SIGN: number;
    CKA_VERIFY: number;
    CKA_SENSITIVE: number;
    CKA_EXTRACTABLE: number;
    CKA_EC_PARAMS: number;
    CKA_EC_POINT: number;
    CKO_PUBLIC_KEY: number;
    CKO_PRIVATE_KEY: number;
    CKK_EC: number;
    CKM_EC_KEY_PAIR_GEN: number;
    CKM_ECDSA: number;
}

/**
 * PKCS#11 KMS adapter.
 *
 * Generates ECDSA P-256 keys directly inside the HSM/token via the
 * PKCS#11 interface and routes all signing through `C_Sign`. Private
 * key material is created with `CKA_SENSITIVE=true` and
 * `CKA_EXTRACTABLE=false` so it never leaves the device.
 *
 * Keys are addressed by their PKCS#11 `CKA_LABEL`, which we set to the
 * caller-supplied `kid`. The label doubles as the `externalKeyId` on
 * the resulting {@link KmsKeyRef}.
 *
 * The native module `pkcs11js` is loaded lazily on first use so
 * deployments that don't configure a PKCS#11 provider don't need the
 * library installed or the platform toolchain to build it.
 */
export class Pkcs11KmsAdapter implements KmsAdapter {
    private readonly logger = new Logger(Pkcs11KmsAdapter.name);

    readonly providerId: string;
    readonly type: KmsProviderType = "pkcs11";
    readonly capabilities: KmsAdapterCapabilities = {
        canCreate: true,
        canImport: false,
        canDelete: true,
        supportedAlgs: ["ES256"],
        defaultAlg: "ES256",
    };

    private readonly config: Pkcs11AdapterConfig;
    private readonly jwkCache = new PublicJwkCache();

    private pkcs11?: Pkcs11Instance;
    private constants?: Pkcs11Constants;
    private session?: Buffer;
    private initPromise?: Promise<void>;

    constructor(config: Pkcs11AdapterConfig) {
        this.providerId = config.providerId;
        this.config = config;
    }

    async generateKey(opts: {
        kid: string;
        alg?: KmsSigningAlg;
    }): Promise<KmsKeyMaterial> {
        const alg = opts.alg ?? this.capabilities.defaultAlg;
        this.assertSupported(alg);
        const { p11, c, session } = await this.ensureSession();

        const idBuf = Buffer.from(opts.kid, "utf8");
        const publicTemplate: Pkcs11Attribute[] = [
            { type: c.CKA_CLASS, value: c.CKO_PUBLIC_KEY },
            { type: c.CKA_KEY_TYPE, value: c.CKK_EC },
            { type: c.CKA_LABEL, value: opts.kid },
            { type: c.CKA_ID, value: idBuf },
            { type: c.CKA_TOKEN, value: true },
            { type: c.CKA_VERIFY, value: true },
            { type: c.CKA_EC_PARAMS, value: P256_OID_DER },
        ];
        const privateTemplate: Pkcs11Attribute[] = [
            { type: c.CKA_CLASS, value: c.CKO_PRIVATE_KEY },
            { type: c.CKA_KEY_TYPE, value: c.CKK_EC },
            { type: c.CKA_LABEL, value: opts.kid },
            { type: c.CKA_ID, value: idBuf },
            { type: c.CKA_TOKEN, value: true },
            { type: c.CKA_PRIVATE, value: true },
            { type: c.CKA_SIGN, value: true },
            { type: c.CKA_SENSITIVE, value: true },
            { type: c.CKA_EXTRACTABLE, value: false },
        ];

        const keys = p11.C_GenerateKeyPair(
            session,
            { mechanism: c.CKM_EC_KEY_PAIR_GEN },
            publicTemplate,
            privateTemplate,
        );

        const publicJwk = await this.readPublicJwk(
            opts.kid,
            alg,
            keys.publicKey,
        );
        this.jwkCache.set(opts.kid, publicJwk);
        return { ref: { externalKeyId: opts.kid, publicJwk, alg } };
    }

    importKey(_opts: {
        kid: string;
        privateJwk: JWK;
        alg?: KmsSigningAlg;
    }): Promise<KmsKeyMaterial> {
        throw new NotImplementedException(
            `Pkcs11KmsAdapter[${this.providerId}]: importKey is not supported — generate keys inside the HSM`,
        );
    }

    async sign(
        ref: KmsKeyRef,
        data: Uint8Array,
        alg?: KmsSigningAlg,
    ): Promise<Uint8Array> {
        if (!ref.externalKeyId) {
            throw new Error(
                `Pkcs11KmsAdapter[${this.providerId}]: missing externalKeyId`,
            );
        }
        const signAlg = alg ?? ref.alg;
        this.assertSupported(signAlg);

        const { p11, c, session } = await this.ensureSession();
        const handle = this.findObject(
            p11,
            c,
            session,
            c.CKO_PRIVATE_KEY,
            ref.externalKeyId,
        );
        if (!handle) {
            throw new Error(
                `Pkcs11KmsAdapter[${this.providerId}]: private key '${ref.externalKeyId}' not found`,
            );
        }

        const digest = createHash("sha256").update(data).digest();
        p11.C_SignInit(session, { mechanism: c.CKM_ECDSA }, handle);
        // ECDSA P-256 signature is exactly 64 bytes (r||s).
        const sig = p11.C_Sign(session, digest, Buffer.alloc(64));
        return new Uint8Array(sig);
    }

    async deleteKey(ref: KmsKeyRef): Promise<void> {
        if (!ref.externalKeyId) return;
        this.jwkCache.invalidate(ref.externalKeyId);
        const { p11, c, session } = await this.ensureSession();
        for (const klass of [c.CKO_PRIVATE_KEY, c.CKO_PUBLIC_KEY]) {
            const handle = this.findObject(
                p11,
                c,
                session,
                klass,
                ref.externalKeyId,
            );
            if (handle) {
                try {
                    p11.C_DestroyObject(session, handle);
                } catch (err) {
                    this.logger.warn(
                        `Failed to destroy PKCS#11 object ${ref.externalKeyId}: ${String(err)}`,
                    );
                }
            }
        }
    }

    async health(): Promise<KmsHealthResult> {
        const start = Date.now();
        try {
            await this.ensureSession();
            return { ok: true, latencyMs: Date.now() - start };
        } catch (err) {
            return {
                ok: false,
                latencyMs: Date.now() - start,
                error: String(err),
            };
        }
    }

    private async ensureSession(): Promise<{
        p11: Pkcs11Instance;
        c: Pkcs11Constants;
        session: Buffer;
    }> {
        this.initPromise ??= this.initialise();
        await this.initPromise;
        if (!this.pkcs11 || !this.constants || !this.session) {
            throw new Error(
                `Pkcs11KmsAdapter[${this.providerId}]: not initialised`,
            );
        }
        return {
            p11: this.pkcs11,
            c: this.constants,
            session: this.session,
        };
    }

    private async initialise(): Promise<void> {
        const mod = await loadPkcs11();
        const c = toConstants(mod);
        const p11 = new mod.PKCS11();
        p11.load(this.config.library);
        p11.C_Initialize();

        const slots = p11.C_GetSlotList(true);
        const slot = this.selectSlot(p11, slots);

        const flags =
            c.CKF_SERIAL_SESSION |
            (this.config.readOnly ? 0 : c.CKF_RW_SESSION);
        const session = p11.C_OpenSession(slot, flags);
        p11.C_Login(session, c.CKU_USER, this.config.pin);

        this.pkcs11 = p11;
        this.constants = c;
        this.session = session;
        this.logger.log(
            `Pkcs11KmsAdapter[${this.providerId}] initialised on ${this.config.library}`,
        );
    }

    private selectSlot(p11: Pkcs11Instance, slots: Buffer[]): Buffer {
        if (slots.length === 0) {
            throw new Error(
                `Pkcs11KmsAdapter[${this.providerId}]: no slots with a token present`,
            );
        }
        if (typeof this.config.slot === "number") {
            const index = this.config.slot;
            const slot = slots[index];
            if (!slot) {
                throw new Error(
                    `Pkcs11KmsAdapter[${this.providerId}]: slot index ${index} out of range (0..${slots.length - 1})`,
                );
            }
            return slot;
        }
        const wantedLabel = this.config.slot.trim();
        for (const slot of slots) {
            const info = p11.C_GetTokenInfo(slot);
            if (info.label.trim() === wantedLabel) return slot;
        }
        throw new Error(
            `Pkcs11KmsAdapter[${this.providerId}]: no slot with token label '${wantedLabel}'`,
        );
    }

    private findObject(
        p11: Pkcs11Instance,
        c: Pkcs11Constants,
        session: Buffer,
        keyClass: number,
        label: string,
    ): Buffer | null {
        p11.C_FindObjectsInit(session, [
            { type: c.CKA_CLASS, value: keyClass },
            { type: c.CKA_LABEL, value: label },
        ]);
        try {
            return p11.C_FindObjects(session);
        } finally {
            p11.C_FindObjectsFinal(session);
        }
    }

    private async readPublicJwk(
        kid: string,
        alg: KmsSigningAlg,
        handle: Buffer,
    ): Promise<JWK> {
        const { p11, c, session } = await this.ensureSession();
        const attrs = p11.C_GetAttributeValue(session, handle, [
            { type: c.CKA_EC_POINT, value: null },
        ]);
        const raw = attrs[0]?.value;
        if (!Buffer.isBuffer(raw)) {
            throw new TypeError(
                `Pkcs11KmsAdapter[${this.providerId}]: CKA_EC_POINT returned non-buffer value`,
            );
        }
        const ecPoint = unwrapEcPoint(raw);
        if (ecPoint.length !== 65 || ecPoint[0] !== 0x04) {
            throw new Error(
                `Pkcs11KmsAdapter[${this.providerId}]: unexpected EC point encoding (length ${ecPoint.length})`,
            );
        }
        const x = ecPoint.subarray(1, 33);
        const y = ecPoint.subarray(33, 65);

        const spki = buildP256Spki(x, y);
        const cryptoKey = await globalThis.crypto.subtle.importKey(
            "spki",
            new Uint8Array(spki),
            { name: "ECDSA", namedCurve: "P-256" },
            true,
            ["verify"],
        );
        const jwk = await exportJWK(cryptoKey);
        jwk.kid = kid;
        jwk.alg = alg;
        return jwk;
    }

    private assertSupported(alg: KmsSigningAlg): void {
        if (!this.capabilities.supportedAlgs.includes(alg)) {
            throw new Error(
                `Pkcs11KmsAdapter[${this.providerId}]: unsupported alg '${alg}'`,
            );
        }
    }
}

/**
 * CKA_EC_POINT is specified as a DER-encoded OCTET STRING wrapping the
 * raw ECPoint. Some HSM vendors return the unwrapped ECPoint directly,
 * so we accept both.
 */
function unwrapEcPoint(raw: Buffer): Buffer {
    if (raw.length >= 2 && raw[0] === 0x04 && raw[1] === raw.length - 2) {
        return raw.subarray(2);
    }
    // Long-form DER length (rare for P-256 — 65 bytes fits short form).
    if (raw.length >= 3 && raw[0] === 0x04 && raw[1] === 0x81) {
        return raw.subarray(3);
    }
    return raw;
}

/**
 * Build a P-256 SubjectPublicKeyInfo around the raw uncompressed EC
 * point so we can hand it to WebCrypto's SPKI importer.
 *
 * Layout (DER): SEQUENCE { AlgorithmIdentifier, BIT STRING { 04 || x || y } }
 * where AlgorithmIdentifier = SEQUENCE { id-ecPublicKey, prime256v1 OID }.
 */
function buildP256Spki(x: Buffer, y: Buffer): Buffer {
    const prefix = Buffer.from([
        0x30,
        0x59, // SEQUENCE, 89 bytes
        0x30,
        0x13, // SEQUENCE, 19 bytes (AlgorithmIdentifier)
        0x06,
        0x07,
        0x2a,
        0x86,
        0x48,
        0xce,
        0x3d,
        0x02,
        0x01, // id-ecPublicKey
        0x06,
        0x08,
        0x2a,
        0x86,
        0x48,
        0xce,
        0x3d,
        0x03,
        0x01,
        0x07, // prime256v1
        0x03,
        0x42,
        0x00,
        0x04, // BIT STRING, 66 bytes, 0 unused, uncompressed
    ]);
    return Buffer.concat([prefix, x, y]);
}

function toConstants(mod: Pkcs11Module): Pkcs11Constants {
    return mod as unknown as Pkcs11Constants;
}

let pkcs11ModulePromise: Promise<Pkcs11Module> | undefined;

async function loadPkcs11(): Promise<Pkcs11Module> {
    pkcs11ModulePromise ??= (async () => {
        try {
            const mod = (await import("pkcs11js")) as unknown as
                | Pkcs11Module
                | { default: Pkcs11Module };
            return "PKCS11" in mod ? mod : mod.default;
        } catch (err) {
            throw new Error(
                `pkcs11js is not installed. Install it with 'pnpm add pkcs11js' in apps/backend before configuring a pkcs11 KMS provider. Original error: ${String(err)}`,
            );
        }
    })();
    return pkcs11ModulePromise;
}

results matching ""

    No results matching ""