From bf6861219900b44f644d39221b0cbacf17645fe1 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 13 Apr 2026 10:51:34 +0300 Subject: [PATCH 01/15] ca: refactor generateSigningCert to delegate to generateSigningCertFromCSR Extract the certificate enrollment logic from generateSigningCert() into a new generateSigningCertFromCSR() method that takes an already-prepared PKCS10 object and a profile ID, then have generateSigningCert() delegate to it after generating the key pair and CSR internally. The new method takes an explicit profileId parameter rather than hard-coding "caCACert", allowing callers to use a different CA signing profile (e.g. one that adds nameConstraints for ACME sub-CAs). No functional change for existing callers. Signed-off-by: Alexander Bokovoy --- .../org/dogtagpki/server/ca/CAEngine.java | 156 ++++++++++++++---- 1 file changed, 120 insertions(+), 36 deletions(-) diff --git a/base/ca/src/main/java/org/dogtagpki/server/ca/CAEngine.java b/base/ca/src/main/java/org/dogtagpki/server/ca/CAEngine.java index ec92021da88..39cf47320aa 100644 --- a/base/ca/src/main/java/org/dogtagpki/server/ca/CAEngine.java +++ b/base/ca/src/main/java/org/dogtagpki/server/ca/CAEngine.java @@ -1274,11 +1274,36 @@ public X509CertImpl generateSigningCert( KeyPair keypair = ca.generateKeyPair(token); PKCS10 pkcs10 = ca.generateCertRequest(keypair, subjectX500Name); + return generateSigningCertFromCSR(ca, pkcs10, authToken, "caCACert"); + } + + /** + * Sign a PKCS#10 CSR as a sub-CA certificate using the specified profile. + * + * This is the common signing path shared by both sub-CA creation modes: + * + * + * @param parentCA the CA that will sign the sub-CA certificate + * @param pkcs10 the PKCS#10 CSR containing the public key to certify + * @param authToken authentication token for the request + * @param profileId Dogtag profile ID to use for signing (e.g. {@code caCACert}) + * @return the signed sub-CA certificate + */ + public X509CertImpl generateSigningCertFromCSR( + CertificateAuthority parentCA, + PKCS10 pkcs10, + AuthToken authToken, + String profileId) + throws Exception { - logger.info("CAEngine: signing certificate"); + logger.info("CAEngine: signing sub-CA CSR with profile {}", profileId); ProfileSubsystem ps = getProfileSubsystem(); - String profileId = "caCACert"; Profile profile = ps.getProfile(profileId); ArgBlock argBlock = new ArgBlock(); @@ -1295,7 +1320,7 @@ public X509CertImpl generateSigningCert( processor.init(); Map resultMap = processor.processEnrollment( - certRequest, null, ca.getAuthorityID(), null, authToken); + certRequest, null, parentCA.getAuthorityID(), null, authToken); com.netscape.cmscore.request.Request[] requests = (com.netscape.cmscore.request.Request[]) resultMap.get(CAProcessor.ARG_REQUESTS); @@ -1303,15 +1328,14 @@ public X509CertImpl generateSigningCert( Integer result = request.getExtDataInInteger(com.netscape.cmscore.request.Request.RESULT); if (result != null && !result.equals(com.netscape.cmscore.request.Request.RES_SUCCESS)) { - throw new EBaseException("Unable to generate signing certificate: " + result); + throw new EBaseException("Unable to sign sub-CA CSR: " + result); } RequestStatus requestStatus = request.getRequestStatus(); if (requestStatus != RequestStatus.COMPLETE) { // The request did not complete. Inference: something - // incorrect in the request (e.g. profile constraint - // violated). - String msg = "Unable to generate signing certificate: " + requestStatus; + // incorrect in the request (e.g. profile constraint violated). + String msg = "Unable to sign sub-CA CSR: " + requestStatus; String errorMsg = request.getExtDataInString(com.netscape.cmscore.request.Request.ERROR); if (errorMsg != null) { msg += ": " + errorMsg; @@ -1334,6 +1358,33 @@ public AuthorityRecord createAuthorityRecord( String subjectDN, String description) throws Exception { + return createAuthorityRecord(parentAID, authToken, subjectDN, description, null); + } + + /** + * Create a CA signed by a parent CA, optionally using an external CSR. + * + * When {@code csrData} is non-null, no key pair is generated locally. + * Dogtag signs the provided PEM-encoded PKCS#10 CSR as a sub-CA + * certificate. The authority's private key stays on the caller's side + * (e.g. in an HSM attached to an ACME server). The authority record is + * stored with the {@link AuthorityRecord#EXTERNAL_KEY_NICKNAME_PREFIX} + * sentinel in {@code authorityKeyNickname}; Dogtag will not attempt + * signing operations for it. + * + * When {@code csrData} is null, existing behaviour is preserved: a key + * pair is generated in the local NSS database (or configured HSM token). + * + * This method DOES NOT add the new CA to CAEngine; it is the + * caller's responsibility. + */ + public AuthorityRecord createAuthorityRecord( + AuthorityID parentAID, + AuthToken authToken, + String subjectDN, + String description, + String csrData) + throws Exception { CertificateAuthority parentCA = getCA(parentAID); @@ -1365,47 +1416,80 @@ public AuthorityRecord createAuthorityRecord( record.setDescription(description); record.setEnabled(true); - CertificateAuthority hostCA = getCA(); - - String keyNickname = hostCA.getNickname() + " " + authorityID; - record.setKeyNickname(keyNickname); + X509CertImpl cert = null; - record.addKeyHost(mConfig.getHostname() + ":" + getEESSLPort()); + if (csrData != null) { + // External key path: the caller holds the private key (e.g. in an + // HSM). Store a sentinel nickname so Dogtag knows not to try to + // load a signing unit for this authority. + record.setKeyNickname( + AuthorityRecord.EXTERNAL_KEY_NICKNAME_PREFIX + authorityID); + // keyHosts intentionally left empty: no IPA server holds the key. - authorityRepository.addAuthorityRecord(record); + authorityRepository.addAuthorityRecord(record); - X509CertImpl cert = null; + try { + PKCS10 pkcs10 = CertUtil.decodePKCS10(csrData); + + // Validate that the CSR subject matches the requested DN. + X500Name csrSubject = pkcs10.getSubjectName(); + if (!csrSubject.equals(subjectX500Name)) { + throw new BadRequestDataException( + "CSR subject DN '" + csrSubject + + "' does not match requested DN '" + subjectX500Name + "'"); + } - try { - int i = keyNickname.indexOf(':'); - String tokenname; - String nickname; + logger.info("CAEngine: Signing external sub-CA CSR"); + cert = generateSigningCertFromCSR(parentCA, pkcs10, authToken, "caCACert"); + // No store.importCert(): the key is external; the certificate + // is tracked solely via the LDAP authority record serial number. - if (i >= 0) { - tokenname = keyNickname.substring(0, i); - nickname = keyNickname.substring(i + 1); - } else { - tokenname = null; - nickname = keyNickname; + } catch (Exception e) { + logger.error("Unable to sign external sub-CA CSR: " + e.getMessage(), e); + authorityRepository.deleteAuthorityRecord(authorityID); + deleteAuthorityEntry(authorityID); + throw e; } - CryptoToken token = CryptoUtil.getKeyStorageToken(tokenname); + } else { + // Local key path: generate a key pair in the local NSS database + // (or the configured HSM token) and sign the sub-CA certificate. + CertificateAuthority hostCA = getCA(); - logger.info("CAEngine: Generating signing certificate"); - cert = generateSigningCert(parentCA, subjectX500Name, authToken, token); + String keyNickname = hostCA.getNickname() + " " + authorityID; + record.setKeyNickname(keyNickname); + record.addKeyHost(mConfig.getHostname() + ":" + getEESSLPort()); - logger.info("CAEngine: Importing " + nickname + " cert into " + token.getName()); - CryptoStore store = token.getCryptoStore(); - store.importCert(cert.getEncoded(), nickname); + authorityRepository.addAuthorityRecord(record); - } catch (Exception e) { - logger.error("Unable to generate signing certificate: " + e.getMessage(), e); + try { + int i = keyNickname.indexOf(':'); + String tokenname; + String nickname; + + if (i >= 0) { + tokenname = keyNickname.substring(0, i); + nickname = keyNickname.substring(i + 1); + } else { + tokenname = null; + nickname = keyNickname; + } + + CryptoToken token = CryptoUtil.getKeyStorageToken(tokenname); + + logger.info("CAEngine: Generating signing certificate"); + cert = generateSigningCert(parentCA, subjectX500Name, authToken, token); - // something went wrong; delete just-added entry - authorityRepository.deleteAuthorityRecord(authorityID); - deleteAuthorityEntry(authorityID); + logger.info("CAEngine: Importing " + nickname + " cert into " + token.getName()); + CryptoStore store = token.getCryptoStore(); + store.importCert(cert.getEncoded(), nickname); - throw e; + } catch (Exception e) { + logger.error("Unable to generate signing certificate: " + e.getMessage(), e); + authorityRepository.deleteAuthorityRecord(authorityID); + deleteAuthorityEntry(authorityID); + throw e; + } } CertId certID = new CertId(cert.getSerialNumber()); From 2dfdc363d7e55675c7a92e6dca94f2ff9dc73ff5 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 13 Apr 2026 12:11:38 +0300 Subject: [PATCH 02/15] ca: add caExternalKeyCACert profile for external-key sub-CA issuance Introduce a dedicated signing profile for lightweight CA authorities whose private key is held by a remote caller (e.g. an ACME server with its own HSM). The profile differs from caCACert in three ways that are appropriate for programmatic sub-CA creation: * RSA-1024 is removed from keyParameters: sub-CA keys shorter than 2048 bits are rejected by keyConstraintImpl. * basicConstraintsPathLen is set to 0 and basicConstraintsMaxPathLen is set to 0: the issued sub-CA cannot itself act as an intermediate CA and sign further sub-CA certificates. * Policy 11 adds userExtensionDefaultImpl: extensions embedded in the submitted CSR (e.g. NameConstraints) are copied verbatim into the issued certificate rather than being silently discarded. The profile is not visible in the enrollment UI (visible=false) because it is intended to be used only by the authority-creation REST path. No changes to caCACert.cfg: local-key sub-CA creation is unaffected. Fixes: https://github.com/dogtagpki/pki/issues/5336 Signed-off-by: Alexander Bokovoy --- base/ca/shared/conf/CS.cfg | 3 +- .../profiles/ca/caExternalKeyCACert.cfg | 99 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 base/ca/shared/profiles/ca/caExternalKeyCACert.cfg diff --git a/base/ca/shared/conf/CS.cfg b/base/ca/shared/conf/CS.cfg index 669abcd1b46..691b6a4653d 100644 --- a/base/ca/shared/conf/CS.cfg +++ b/base/ca/shared/conf/CS.cfg @@ -827,7 +827,7 @@ oidmap.pse.class=org.mozilla.jss.netscape.security.extensions.PresenceServerExte oidmap.pse.oid=2.16.840.1.113730.1.18 oidmap.subject_info_access.class=org.mozilla.jss.netscape.security.extensions.SubjectInfoAccessExtension oidmap.subject_info_access.oid=1.3.6.1.5.5.7.1.11 -profile.list=acmeServerCert,caCMCserverCert,caCMCECserverCert,caCMCECsubsystemCert,caCMCsubsystemCert,caCMCauditSigningCert,caCMCcaCert,caCMCocspCert,caCMCkraTransportCert,caCMCkraStorageCert,caServerKeygen_UserCert,caServerKeygen_DirUserCert,caUserCert,caECUserCert,caMLDSAUserCert,caUserSMIMEcapCert,caDualCert,caDirBasedDualCert,AdminCert,ECAdminCert,caSignedLogCert,caTPSCert,caRARouterCert,caRouterCert,caServerCert,caECServerCert,caMLDSAServerCert,caServerCertWithSCT,caECServerCertWithSCT,caSubsystemCert,caECSubsystemCert,caMLDSASubsystemCert,caOtherCert,caCACert,caCMCcaCert,caCrossSignedCACert,caInstallCACert,caRACert,caOCSPCert,caStorageCert,caTransportCert,caDirPinUserCert,caECDirPinUserCert,caDirUserCert,caECDirUserCert,caAgentServerCert,caECAgentServerCert,caAgentFileSigning,caCMCUserCert,caCMCECUserCert,caCMCcaIssuanceProtectionCert,caFullCMCUserCert,caECFullCMCUserCert,caFullCMCUserSignedCert,caECFullCMCUserSignedCert,caFullCMCSharedTokenCert,caECFullCMCSharedTokenCert,caSimpleCMCUserCert,caECSimpleCMCUserCert,caTokenDeviceKeyEnrollment,caTokenUserEncryptionKeyEnrollment,caTokenUserSigningKeyEnrollment,caTempTokenDeviceKeyEnrollment,caTempTokenUserEncryptionKeyEnrollment,caTempTokenUserSigningKeyEnrollment,caAdminCert,caECAdminCert,caMLDSAAdminCert,caInternalAuthServerCert,caECInternalAuthServerCert,caMLDSAInternalAuthServerCert,caInternalAuthTransportCert,caInternalAuthDRMstorageCert,caInternalAuthSubsystemCert,caECInternalAuthSubsystemCert,caMLDSAInternalAuthSubsystemCert,caInternalAuthOCSPCert,caInternalAuthAuditSigningCert,DomainController,caDualRAuserCert,caRAagentCert,caRAserverCert,caUUIDdeviceCert,caSSLClientSelfRenewal,caDirUserRenewal,caManualRenewal,caTokenMSLoginEnrollment,caTokenUserSigningKeyRenewal,caTokenUserEncryptionKeyRenewal,caTokenUserAuthKeyRenewal,caJarSigningCert,caIPAserviceCert,caAuditSigningCert,caEncUserCert,caSigningUserCert,caTokenUserDelegateAuthKeyEnrollment,caTokenUserDelegateSigningKeyEnrollment,estServiceCert,estFullcmcDeviceCert +profile.list=acmeServerCert,caCMCserverCert,caCMCECserverCert,caCMCECsubsystemCert,caCMCsubsystemCert,caCMCauditSigningCert,caCMCcaCert,caCMCocspCert,caCMCkraTransportCert,caCMCkraStorageCert,caServerKeygen_UserCert,caServerKeygen_DirUserCert,caUserCert,caECUserCert,caMLDSAUserCert,caUserSMIMEcapCert,caDualCert,caDirBasedDualCert,AdminCert,ECAdminCert,caSignedLogCert,caTPSCert,caRARouterCert,caRouterCert,caServerCert,caECServerCert,caMLDSAServerCert,caServerCertWithSCT,caECServerCertWithSCT,caSubsystemCert,caECSubsystemCert,caMLDSASubsystemCert,caOtherCert,caCACert,caCMCcaCert,caCrossSignedCACert,caInstallCACert,caRACert,caOCSPCert,caStorageCert,caTransportCert,caDirPinUserCert,caECDirPinUserCert,caDirUserCert,caECDirUserCert,caAgentServerCert,caECAgentServerCert,caAgentFileSigning,caCMCUserCert,caCMCECUserCert,caCMCcaIssuanceProtectionCert,caFullCMCUserCert,caECFullCMCUserCert,caFullCMCUserSignedCert,caECFullCMCUserSignedCert,caFullCMCSharedTokenCert,caECFullCMCSharedTokenCert,caSimpleCMCUserCert,caECSimpleCMCUserCert,caTokenDeviceKeyEnrollment,caTokenUserEncryptionKeyEnrollment,caTokenUserSigningKeyEnrollment,caTempTokenDeviceKeyEnrollment,caTempTokenUserEncryptionKeyEnrollment,caTempTokenUserSigningKeyEnrollment,caAdminCert,caECAdminCert,caMLDSAAdminCert,caInternalAuthServerCert,caECInternalAuthServerCert,caMLDSAInternalAuthServerCert,caInternalAuthTransportCert,caInternalAuthDRMstorageCert,caInternalAuthSubsystemCert,caECInternalAuthSubsystemCert,caMLDSAInternalAuthSubsystemCert,caInternalAuthOCSPCert,caInternalAuthAuditSigningCert,DomainController,caDualRAuserCert,caRAagentCert,caRAserverCert,caUUIDdeviceCert,caSSLClientSelfRenewal,caDirUserRenewal,caManualRenewal,caTokenMSLoginEnrollment,caTokenUserSigningKeyRenewal,caTokenUserEncryptionKeyRenewal,caTokenUserAuthKeyRenewal,caJarSigningCert,caIPAserviceCert,caAuditSigningCert,caEncUserCert,caSigningUserCert,caTokenUserDelegateAuthKeyEnrollment,caTokenUserDelegateSigningKeyEnrollment,estServiceCert,estFullcmcDeviceCert,caExternalKeyCACert profile.acmeServerCert.class_id=caEnrollImpl profile.caUUIDdeviceCert.class_id=caEnrollImpl profile.caManualRenewal.class_id=caEnrollImpl @@ -852,6 +852,7 @@ profile.caCMCECserverCert.class_id=caEnrollImpl profile.caCMCsubsystemCert.class_id=caEnrollImpl profile.caCMCECsubsystemCert.class_id=caEnrollImpl profile.caCACert.class_id=caEnrollImpl +profile.caExternalKeyCACert.class_id=caEnrollImpl profile.caInstallCACert.class_id=caEnrollImpl profile.caCrossSignedCACert.class_id=caEnrollImpl profile.caServerKeygen_UserCert.class_id=caEnrollImpl diff --git a/base/ca/shared/profiles/ca/caExternalKeyCACert.cfg b/base/ca/shared/profiles/ca/caExternalKeyCACert.cfg new file mode 100644 index 00000000000..2e304b5717a --- /dev/null +++ b/base/ca/shared/profiles/ca/caExternalKeyCACert.cfg @@ -0,0 +1,99 @@ +desc=Certificate profile for signing a sub-CA certificate from an externally provided CSR. The private key is held by the caller (e.g. in an HSM attached to a remote ACME server); Dogtag only signs the public-key material submitted in the CSR. Compared with caCACert, this profile enforces pathLen=0 (sub-CA cannot issue further sub-CAs), rejects RSA keys shorter than 2048 bits, and copies extensions supplied in the CSR (e.g. NameConstraints) verbatim into the issued certificate. +visible=false +enable=true +enableBy=admin +auth.class_id= +name=External Key Sub-CA Signing Certificate Enrollment +input.list=i1,i2 +input.i1.class_id=certReqInputImpl +input.i2.class_id=submitterInfoInputImpl +output.list=o1 +output.o1.class_id=certOutputImpl +policyset.list=caCertSet +policyset.caCertSet.list=1,2,3,8,4,5,6,9,10,11 +policyset.caCertSet.1.constraint.class_id=subjectNameConstraintImpl +policyset.caCertSet.1.constraint.name=Subject Name Constraint +policyset.caCertSet.1.constraint.params.pattern=CN=.* +policyset.caCertSet.1.constraint.params.accept=true +policyset.caCertSet.1.default.class_id=userSubjectNameDefaultImpl +policyset.caCertSet.1.default.name=Subject Name Default +policyset.caCertSet.1.default.params.name= +policyset.caCertSet.2.constraint.class_id=validityConstraintImpl +policyset.caCertSet.2.constraint.name=Validity Constraint +policyset.caCertSet.2.constraint.params.range=7305 +policyset.caCertSet.2.constraint.params.notBeforeCheck=false +policyset.caCertSet.2.constraint.params.notAfterCheck=false +policyset.caCertSet.2.default.class_id=caValidityDefaultImpl +policyset.caCertSet.2.default.name=CA Certificate Validity Default +policyset.caCertSet.2.default.params.range=7305 +policyset.caCertSet.2.default.params.startTime=0 +policyset.caCertSet.3.constraint.class_id=keyConstraintImpl +policyset.caCertSet.3.constraint.name=Key Constraint +policyset.caCertSet.3.constraint.params.keyType=- +policyset.caCertSet.3.constraint.params.keyParameters=2048,3072,4096,nistp256,nistp384,nistp521 +policyset.caCertSet.3.default.class_id=userKeyDefaultImpl +policyset.caCertSet.3.default.name=Key Default +policyset.caCertSet.4.constraint.class_id=noConstraintImpl +policyset.caCertSet.4.constraint.name=No Constraint +policyset.caCertSet.4.default.class_id=authorityKeyIdentifierExtDefaultImpl +policyset.caCertSet.4.default.name=Authority Key Identifier Default +policyset.caCertSet.5.constraint.class_id=basicConstraintsExtConstraintImpl +policyset.caCertSet.5.constraint.name=Basic Constraint Extension Constraint +policyset.caCertSet.5.constraint.params.basicConstraintsCritical=true +policyset.caCertSet.5.constraint.params.basicConstraintsIsCA=true +policyset.caCertSet.5.constraint.params.basicConstraintsMinPathLen=-1 +policyset.caCertSet.5.constraint.params.basicConstraintsMaxPathLen=0 +policyset.caCertSet.5.default.class_id=basicConstraintsExtDefaultImpl +policyset.caCertSet.5.default.name=Basic Constraints Extension Default +policyset.caCertSet.5.default.params.basicConstraintsCritical=true +policyset.caCertSet.5.default.params.basicConstraintsIsCA=true +policyset.caCertSet.5.default.params.basicConstraintsPathLen=0 +policyset.caCertSet.6.constraint.class_id=keyUsageExtConstraintImpl +policyset.caCertSet.6.constraint.name=Key Usage Extension Constraint +policyset.caCertSet.6.constraint.params.keyUsageCritical=true +policyset.caCertSet.6.constraint.params.keyUsageDigitalSignature=false +policyset.caCertSet.6.constraint.params.keyUsageNonRepudiation=false +policyset.caCertSet.6.constraint.params.keyUsageDataEncipherment=false +policyset.caCertSet.6.constraint.params.keyUsageKeyEncipherment=false +policyset.caCertSet.6.constraint.params.keyUsageKeyAgreement=false +policyset.caCertSet.6.constraint.params.keyUsageKeyCertSign=true +policyset.caCertSet.6.constraint.params.keyUsageCrlSign=true +policyset.caCertSet.6.constraint.params.keyUsageEncipherOnly=false +policyset.caCertSet.6.constraint.params.keyUsageDecipherOnly=false +policyset.caCertSet.6.default.class_id=keyUsageExtDefaultImpl +policyset.caCertSet.6.default.name=Key Usage Default +policyset.caCertSet.6.default.params.keyUsageCritical=true +policyset.caCertSet.6.default.params.keyUsageDigitalSignature=false +policyset.caCertSet.6.default.params.keyUsageNonRepudiation=false +policyset.caCertSet.6.default.params.keyUsageDataEncipherment=false +policyset.caCertSet.6.default.params.keyUsageKeyEncipherment=false +policyset.caCertSet.6.default.params.keyUsageKeyAgreement=false +policyset.caCertSet.6.default.params.keyUsageKeyCertSign=true +policyset.caCertSet.6.default.params.keyUsageCrlSign=true +policyset.caCertSet.6.default.params.keyUsageEncipherOnly=false +policyset.caCertSet.6.default.params.keyUsageDecipherOnly=false +policyset.caCertSet.8.constraint.class_id=noConstraintImpl +policyset.caCertSet.8.constraint.name=No Constraint +policyset.caCertSet.8.default.class_id=subjectKeyIdentifierExtDefaultImpl +policyset.caCertSet.8.default.name=Subject Key Identifier Extension Default +policyset.caCertSet.8.default.params.critical=false +policyset.caCertSet.9.constraint.class_id=signingAlgConstraintImpl +policyset.caCertSet.9.constraint.name=No Constraint +policyset.caCertSet.9.constraint.params.signingAlgsAllowed=SHA256withRSA,SHA512withRSA,SHA256withEC,SHA384withRSA,SHA384withEC,SHA512withEC,SHA256withRSA/PSS,SHA384withRSA/PSS,SHA512withRSA/PSS,ML-DSA-44,ML-DSA-65,ML-DSA-87 +policyset.caCertSet.9.default.class_id=signingAlgDefaultImpl +policyset.caCertSet.9.default.name=Signing Alg +policyset.caCertSet.9.default.params.signingAlg=- +policyset.caCertSet.10.constraint.class_id=noConstraintImpl +policyset.caCertSet.10.constraint.name=No Constraint +policyset.caCertSet.10.default.class_id=authInfoAccessExtDefaultImpl +policyset.caCertSet.10.default.name=AIA Extension Default +policyset.caCertSet.10.default.params.authInfoAccessADEnable_0=true +policyset.caCertSet.10.default.params.authInfoAccessADLocationType_0=URIName +policyset.caCertSet.10.default.params.authInfoAccessADLocation_0= +policyset.caCertSet.10.default.params.authInfoAccessADMethod_0=1.3.6.1.5.5.7.48.1 +policyset.caCertSet.10.default.params.authInfoAccessCritical=false +policyset.caCertSet.10.default.params.authInfoAccessNumADs=1 +policyset.caCertSet.11.constraint.class_id=noConstraintImpl +policyset.caCertSet.11.constraint.name=No Constraint +policyset.caCertSet.11.default.class_id=userExtensionDefaultImpl +policyset.caCertSet.11.default.name=User Supplied Extension Default From 1dc519f6d17c99ebb9dcbb9d1ed259f6c556295f Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 13 Apr 2026 10:51:53 +0300 Subject: [PATCH 03/15] ca: support external CSR when creating a lightweight sub-CA Allow POST /v2/authorities to accept an optional csrData field containing a PEM-encoded PKCS#10 CSR. When present, Dogtag signs the CSR as a sub-CA certificate without generating a local key pair. The authority's private key remains with the caller (e.g. in an HSM attached to a remote ACME server). The authority record stores a sentinel value in authorityKeyNickname ("#external#:") so Dogtag can distinguish external-key authorities from local-key ones. Such authorities report externalKey=true and ready=false via the REST API and never have a signing unit initialised. An optional profileId field in the same request body selects the signing profile used to issue the sub-CA certificate. When absent, the engine defaults to caExternalKeyCACert, which enforces pathLen=0, rejects RSA keys shorter than 2048 bits, and copies CSR-embedded extensions (e.g. NameConstraints) into the issued certificate. Changes: AuthorityData: add csrData (input-only CSR field), externalKey (read-only output), and profileId (optional profile selection; not stored or returned after creation) AuthorityRecord: EXTERNAL_KEY_NICKNAME_PREFIX constant + isExternalKey() derived method AuthorityRepository: sentinel detection in readAuthorityData(); csrData and profileId forwarded to engine in createCA() CAEngine: two-branch createAuthorityRecord() (external vs local key); new generateSigningCertFromCSR(profileId) method; 4-param overload preserved for internal callers that do not supply csrData CertificateAuthority: guard against signing unit initialisation for external-key CAs (authorityKeyNickname starts with "#external#:") Fixes: https://github.com/dogtagpki/pki/issues/5336 Signed-off-by: Alexander Bokovoy --- .../com/netscape/ca/CertificateAuthority.java | 12 ++++ .../dogtagpki/server/ca/AuthorityRecord.java | 25 ++++++++ .../org/dogtagpki/server/ca/CAEngine.java | 37 +++++++++-- .../ca/rest/base/AuthorityRepository.java | 21 ++++++- .../certsrv/authority/AuthorityData.java | 61 ++++++++++++++++++- 5 files changed, 145 insertions(+), 11 deletions(-) diff --git a/base/ca/src/main/java/com/netscape/ca/CertificateAuthority.java b/base/ca/src/main/java/com/netscape/ca/CertificateAuthority.java index 37426d05cc6..0b7b154a07d 100644 --- a/base/ca/src/main/java/com/netscape/ca/CertificateAuthority.java +++ b/base/ca/src/main/java/com/netscape/ca/CertificateAuthority.java @@ -38,6 +38,7 @@ import java.util.Date; import java.util.Vector; +import org.dogtagpki.server.ca.AuthorityRecord; import org.dogtagpki.server.ca.CAConfig; import org.dogtagpki.server.ca.CAEngine; import org.dogtagpki.server.ca.CAEngineConfig; @@ -688,6 +689,17 @@ public void initSigningUnits() throws Exception { logger.info("CertificateAuthority: Initializing " + (authorityID == null ? "host CA" : "authority " + authorityID)); + // External-key authorities have their private key held outside Dogtag + // (e.g. in an HSM attached to a remote ACME server). The key nickname + // carries the #external#: sentinel instead of a real NSS token:nickname + // pair. Skip signing unit initialisation: hasKeys stays false, + // isReady() returns false, and no key retrieval is attempted. + if (mNickname != null + && mNickname.startsWith(AuthorityRecord.EXTERNAL_KEY_NICKNAME_PREFIX)) { + logger.info("CertificateAuthority: external key — signing unit not initialized"); + return; + } + try { initCertSigningUnit(); initCRLSigningUnit(); diff --git a/base/ca/src/main/java/org/dogtagpki/server/ca/AuthorityRecord.java b/base/ca/src/main/java/org/dogtagpki/server/ca/AuthorityRecord.java index 99c6965468f..c569f33bbf3 100644 --- a/base/ca/src/main/java/org/dogtagpki/server/ca/AuthorityRecord.java +++ b/base/ca/src/main/java/org/dogtagpki/server/ca/AuthorityRecord.java @@ -29,6 +29,19 @@ public class AuthorityRecord { Boolean enabled; CertId serialNumber; + /** + * Sentinel prefix stored in authorityKeyNickname for externally-keyed CAs. + * + * An externally-keyed CA has its private key held outside Dogtag — for + * example in an HSM attached to a remote ACME server. Dogtag signs the + * sub-CA certificate from a caller-supplied CSR and tracks the authority + * for revocation purposes, but never performs signing operations for it. + * + * The sentinel is prefixed with '#' which is not a valid NSS token-name + * character, so it cannot be mistaken for a real token:nickname pair. + */ + public static final String EXTERNAL_KEY_NICKNAME_PREFIX = "#external#:"; + String keyNickname; Collection keyHosts = new ArrayList<>(); @@ -99,6 +112,18 @@ public void setKeyNickname(String keyNickname) { this.keyNickname = keyNickname; } + /** + * Return true if this authority's private key is held externally. + * + * Externally-keyed authorities have a sentinel value in authorityKeyNickname + * rather than a real NSS token:nickname pair. Dogtag tracks them for + * certificate issuance and revocation but does not perform signing for them. + */ + public boolean isExternalKey() { + return keyNickname != null + && keyNickname.startsWith(EXTERNAL_KEY_NICKNAME_PREFIX); + } + public Collection getKeyHosts() { return keyHosts; } diff --git a/base/ca/src/main/java/org/dogtagpki/server/ca/CAEngine.java b/base/ca/src/main/java/org/dogtagpki/server/ca/CAEngine.java index 39cf47320aa..8530bed7ee7 100644 --- a/base/ca/src/main/java/org/dogtagpki/server/ca/CAEngine.java +++ b/base/ca/src/main/java/org/dogtagpki/server/ca/CAEngine.java @@ -1305,6 +1305,9 @@ public X509CertImpl generateSigningCertFromCSR( ProfileSubsystem ps = getProfileSubsystem(); Profile profile = ps.getProfile(profileId); + if (profile == null) { + throw new BadRequestDataException("Unknown profile: " + profileId); + } ArgBlock argBlock = new ArgBlock(); argBlock.set("cert_request_type", "pkcs10"); @@ -1358,7 +1361,7 @@ public AuthorityRecord createAuthorityRecord( String subjectDN, String description) throws Exception { - return createAuthorityRecord(parentAID, authToken, subjectDN, description, null); + return createAuthorityRecord(parentAID, authToken, subjectDN, description, null, null); } /** @@ -1383,7 +1386,8 @@ public AuthorityRecord createAuthorityRecord( AuthToken authToken, String subjectDN, String description, - String csrData) + String csrData, + String profileId) throws Exception { CertificateAuthority parentCA = getCA(parentAID); @@ -1429,9 +1433,12 @@ public AuthorityRecord createAuthorityRecord( authorityRepository.addAuthorityRecord(record); try { - PKCS10 pkcs10 = CertUtil.decodePKCS10(csrData); + PKCS10 pkcs10 = parsePKCS10(Locale.getDefault(), csrData); // Validate that the CSR subject matches the requested DN. + // Per RFC 5280 §4.1.2.6, CA subject DNs must be encoded + // identically to the issuer field in certificates they issue, + // so byte-level DER comparison is appropriate here. X500Name csrSubject = pkcs10.getSubjectName(); if (!csrSubject.equals(subjectX500Name)) { throw new BadRequestDataException( @@ -1439,8 +1446,15 @@ public AuthorityRecord createAuthorityRecord( + "' does not match requested DN '" + subjectX500Name + "'"); } - logger.info("CAEngine: Signing external sub-CA CSR"); - cert = generateSigningCertFromCSR(parentCA, pkcs10, authToken, "caCACert"); + // Use the caller-supplied profile or fall back to the profile + // designed for external-key sub-CA issuance. + String effectiveProfileId = (profileId != null && !profileId.isBlank()) + ? profileId + : "caExternalKeyCACert"; + + logger.info("CAEngine: Signing external sub-CA CSR with profile '{}'", + effectiveProfileId); + cert = generateSigningCertFromCSR(parentCA, pkcs10, authToken, effectiveProfileId); // No store.importCert(): the key is external; the certificate // is tracked solely via the LDAP authority record serial number. @@ -1773,7 +1787,11 @@ public void revokeAuthority( throw new ECAException("Unable to create CRL extensions", e); } - X509CertImpl caCertImpl = ca.getSigningUnit().getCertImpl(); + // For external-key authorities the signing unit is not initialized; + // the certificate is already in the repository (fetched above). + X509CertImpl caCertImpl = ca.getSigningUnit() != null + ? ca.getSigningUnit().getCertImpl() + : certRecord.getCertificate(); processor.addCertificateToRevoke(caCertImpl); processor.createRevocationRequest(); @@ -1792,6 +1810,13 @@ public void deleteAuthorityNSSDB(CertificateAuthority ca) throws ECAException { return; } + if (ca.getSigningUnit() == null) { + // External-key authority: no cert or private key was ever stored in + // the local NSS database, so there is nothing to remove. + logger.info("CAEngine: external-key authority — skipping NSS DB cleanup"); + return; + } + ca.deleteAuthorityNSSDB(); } diff --git a/base/ca/src/main/java/org/dogtagpki/server/ca/rest/base/AuthorityRepository.java b/base/ca/src/main/java/org/dogtagpki/server/ca/rest/base/AuthorityRepository.java index fce66b1776e..4c245ac7d16 100644 --- a/base/ca/src/main/java/org/dogtagpki/server/ca/rest/base/AuthorityRepository.java +++ b/base/ca/src/main/java/org/dogtagpki/server/ca/rest/base/AuthorityRepository.java @@ -615,7 +615,9 @@ public AuthorityData createCA(AuthorityData data) { parentAID, authToken, data.getDN(), - data.getDescription()); + data.getDescription(), + data.getCsrData(), + data.getProfileId()); audit(ILogger.SUCCESS, OpDef.OP_ADD, record.getAuthorityID().toString(), auditParams); return readAuthorityData(record); @@ -777,7 +779,7 @@ private AuthorityData readAuthorityData(CertificateAuthority ca) } AuthorityID parentAID = ca.getAuthorityParentID(); - return new AuthorityData( + AuthorityData data = new AuthorityData( ca.isHostAuthority(), dn, ca.getAuthorityID().toString(), @@ -788,6 +790,13 @@ private AuthorityData readAuthorityData(CertificateAuthority ca) ca.getAuthorityDescription(), ca.isReady() ); + + String nickname = ca.getNickname(); + if (nickname != null && nickname.startsWith(AuthorityRecord.EXTERNAL_KEY_NICKNAME_PREFIX)) { + data.setExternalKey(true); + } + + return data; } private AuthorityData readAuthorityData(AuthorityRecord record) { @@ -834,7 +843,7 @@ private AuthorityData readAuthorityData(AuthorityRecord record) { } } - return new AuthorityData( + AuthorityData data = new AuthorityData( isHostAuthority, authorityDN.toString(), authorityID.toString(), @@ -845,6 +854,12 @@ private AuthorityData readAuthorityData(AuthorityRecord record) { description, isReady ); + + if (record.isExternalKey()) { + data.setExternalKey(true); + } + + return data; } private String toPem(String name, byte[] data) { diff --git a/base/common/src/main/java/com/netscape/certsrv/authority/AuthorityData.java b/base/common/src/main/java/com/netscape/certsrv/authority/AuthorityData.java index c9278cb43c0..255375bf1f9 100644 --- a/base/common/src/main/java/com/netscape/certsrv/authority/AuthorityData.java +++ b/base/common/src/main/java/com/netscape/certsrv/authority/AuthorityData.java @@ -116,6 +116,62 @@ public void setDescription(String description) { this.description = description; } + /** + * PEM-encoded PKCS#10 CSR for an externally-held CA key. + * + * When provided at creation time, Dogtag signs this CSR as a sub-CA + * certificate without generating a local key pair. The CA's private key + * stays on the caller's side (e.g. in an HSM attached to an ACME server). + * This field is consumed at creation and is not stored or returned + * in subsequent GET responses. + */ + private String csrData; + + public String getCsrData() { + return csrData; + } + + public void setCsrData(String csrData) { + this.csrData = csrData; + } + + /** + * Signing profile to use when signing the external CSR (input-only). + * + * When present, overrides the default profile used by + * {@link org.dogtagpki.server.ca.CAEngine#generateSigningCertFromCSR}. + * Ignored when {@link #csrData} is absent. Not stored or returned in + * subsequent GET responses. + * + *

If absent, the engine defaults to {@code caExternalKeyCACert}. + */ + private String profileId; + + public String getProfileId() { + return profileId; + } + + public void setProfileId(String profileId) { + this.profileId = profileId; + } + + /** + * Whether this CA's private key is held externally (read-only). + * + * True when the authority was created with an external CSR. Such a CA + * is tracked by Dogtag for certificate issuance and revocation but is + * never asked to perform signing operations locally. + */ + private Boolean externalKey; + + public Boolean getExternalKey() { + return externalKey; + } + + public void setExternalKey(Boolean externalKey) { + this.externalKey = externalKey; + } + /** * Whether the CA is ready to perform signing operations. * @@ -153,7 +209,7 @@ public AuthorityData( @Override public int hashCode() { - return Objects.hash(description, dn, enabled, id, isHostAuthority, issuerDN, parentID, ready, serial); + return Objects.hash(description, dn, enabled, externalKey, id, isHostAuthority, issuerDN, parentID, ready, serial); } @Override @@ -166,7 +222,8 @@ public boolean equals(Object obj) { return false; AuthorityData other = (AuthorityData) obj; return Objects.equals(description, other.description) && Objects.equals(dn, other.dn) - && Objects.equals(enabled, other.enabled) && Objects.equals(id, other.id) + && Objects.equals(enabled, other.enabled) && Objects.equals(externalKey, other.externalKey) + && Objects.equals(id, other.id) && Objects.equals(isHostAuthority, other.isHostAuthority) && Objects.equals(issuerDN, other.issuerDN) && Objects.equals(ready, other.ready) && Objects.equals(serial, other.serial); } From 7c441ddb98a8cf14b96855b6765b0a8173d8d588 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 13 Apr 2026 11:05:42 +0300 Subject: [PATCH 04/15] docs: document external-key sub-CA creation Add a user guide explaining how to create a lightweight CA authority where the private key is held externally (e.g. in an ACME server's HSM) by supplying a PKCS#10 CSR at creation time. Update the v11.10.0 API change log to describe the new csrData input field and externalKey output field on the /v2/authorities endpoint. Fixes: https://github.com/dogtagpki/pki/issues/5336 Signed-off-by: Alexander Bokovoy --- docs/changes/v11.10.0/API-Changes.adoc | 28 +++ .../Creating-Sub-CA-with-External-Key.adoc | 182 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 docs/user/tools/Creating-Sub-CA-with-External-Key.adoc diff --git a/docs/changes/v11.10.0/API-Changes.adoc b/docs/changes/v11.10.0/API-Changes.adoc index 162c4e5a221..9b814d9ff91 100644 --- a/docs/changes/v11.10.0/API-Changes.adoc +++ b/docs/changes/v11.10.0/API-Changes.adoc @@ -1,5 +1,33 @@ = API Changes = +== Signing Profile Selection and External Key Support in CA Authority Creation == + +`POST /ca/v2/authorities` now accepts two optional fields: + +* `csrData` — a PEM-encoded PKCS#10 CSR. When present, Dogtag signs the CSR + as a sub-CA certificate without generating a local key pair. The authority's + private key remains with the caller (for example in an HSM attached to an + ACME server). + +* `profileId` — the name of the signing profile to use when issuing the sub-CA + certificate. Accepted for both local-key and external-key creation: + ** Without `csrData`: defaults to `caCACert` (existing behaviour). + ** With `csrData`: defaults to `caExternalKeyCACert`, which enforces + `pathLen=0`, rejects RSA keys shorter than 2048 bits, and copies + CSR-embedded extensions (e.g. `NameConstraints`) into the issued + certificate. + +`GET /ca/v2/authorities/` now returns a boolean `externalKey` field for +authorities created with `csrData`. Such authorities also return `ready: false` +because Dogtag does not hold their signing key and cannot issue certificates +on their behalf. + +Both `csrData` and `profileId` are consumed at creation time and are not +stored or returned in subsequent GET responses. + +See link:../../user/tools/Creating-Sub-CA-with-External-Key.adoc[Creating a +Sub-CA with an Externally-Held Key] for usage details. + == Remove KeyClient.archive_key() == The `KeyClient.archive_key()` has been removed. diff --git a/docs/user/tools/Creating-Sub-CA-with-External-Key.adoc b/docs/user/tools/Creating-Sub-CA-with-External-Key.adoc new file mode 100644 index 00000000000..5d04cf6d36c --- /dev/null +++ b/docs/user/tools/Creating-Sub-CA-with-External-Key.adoc @@ -0,0 +1,182 @@ += Creating a Sub-CA with an Externally-Held Key = + +== Overview == + +By default, when a lightweight CA (authority) is created in Dogtag, the CA key +pair is generated on the same PKCS#11 token as the parent CA. This is +unsuitable when a remote service (such as an ACME server) must hold the signing +key in its own HSM to satisfy local security policy, because that policy +requires the key never to leave the device where it was generated. + +In this case the caller generates the CA key pair on the remote system, submits +a PKCS#10 CSR to Dogtag, and receives a signed sub-CA certificate in return. +Dogtag tracks the resulting authority for certificate chain and revocation +purposes but never holds the private key. Such an authority reports +`externalKey: true` and `ready: false` in its metadata because Dogtag cannot +perform signing operations on its behalf. + +== Step 1: Generate the CA Key Pair and CSR == + +Perform this step on the system that will hold the private key (e.g., the +ACME server with its own HSM). + +=== Software token (testing) === + +[source,bash] +---- +openssl genrsa -out /tmp/acme-ca.key 3072 + +openssl req -new \ + -key /tmp/acme-ca.key \ + -subj "CN=ACME Sub-CA,O=EXAMPLE.COM" \ + -out /tmp/acme-ca.csr +---- + +=== HSM (production) === + +Use the HSM vendor's tools or `pkcs11-tool` to generate the key pair and +produce a CSR on the token. The private key must never leave the HSM. + +The subject DN in the CSR must exactly match the `dn` field you will send in +the creation request; Dogtag rejects requests where the two differ. + +== Step 2: Obtain the Parent CA ID == + +[source,bash] +---- +$ pki ca-authority-find +---- + +Note the `ID` of the authority that will be the parent of the new sub-CA. For +a typical single-CA deployment this is the host authority. + +== Step 3: Submit the CSR to Dogtag == + +Use the `pki ca-authority-create` command with `--csr-file`: + +[source,bash] +---- +PARENT_ID="" + +pki -n caadmin ca-authority-create \ + --parent "${PARENT_ID}" \ + --csr-file /tmp/acme-ca.csr \ + --desc "ACME server HSM-backed sub-CA" \ + "CN=ACME Sub-CA,O=EXAMPLE.COM" +---- + +By default the `caExternalKeyCACert` profile is used to sign the sub-CA +certificate. This profile enforces `pathLen=0` (the sub-CA cannot itself +issue further sub-CAs), rejects RSA keys shorter than 2048 bits, and copies +any extensions present in the submitted CSR (such as `NameConstraints`) +verbatim into the issued certificate. + +To use a different profile — for example one that restricts the sub-CA to a +specific DNS domain via `NameConstraints` — add `--profile`: + +[source,bash] +---- +pki -n caadmin ca-authority-create \ + --parent "${PARENT_ID}" \ + --csr-file /tmp/acme-ca.csr \ + --profile myCustomSubCAProfile \ + --desc "ACME server HSM-backed sub-CA" \ + "CN=ACME Sub-CA,O=EXAMPLE.COM" +---- + +On success the command prints the new authority's ID and confirms the +`External key` flag: + +[source] +---- + Authority DN: CN=ACME Sub-CA,O=EXAMPLE.COM + ID: 3f4a9c21-1b2e-4d57-a891-0c3e7b5d9f10 + Parent ID: 00000000-0000-0000-0000-000000000001 + Issuer DN: CN=Certificate Authority,O=EXAMPLE.COM + Serial no: 0xD + Enabled: true + Ready to sign: false + External key: true + Description: ACME server HSM-backed sub-CA +---- + +The `--csr-file` and `--profile` options are creation-time inputs only; +they are not stored or retrievable after the authority is created. + +NOTE: `--profile` is not exclusive to external-key creation. It can also +be used without `--csr-file` to select the signing profile for a +local-key sub-CA, in which case the default is `caCACert`. + +== Step 4: Retrieve the Signed Sub-CA Certificate == + +[source,bash] +---- +AID="3f4a9c21-1b2e-4d57-a891-0c3e7b5d9f10" + +# PEM certificate only +curl -s -H "Accept: application/x-pem-file" \ + https://ca.example.com:8443/ca/v2/authorities/${AID}/cert \ + -o /tmp/acme-subca.pem + +# Full PEM chain (sub-CA + issuer chain up to root) +curl -s -H "Accept: application/x-pem-file" \ + https://ca.example.com:8443/ca/v2/authorities/${AID}/chain \ + -o /tmp/acme-subca-chain.pem +---- + +Verify the certificate chains to the root CA: + +[source,bash] +---- +openssl verify -CAfile /etc/pki/ca-trust/source/anchors/ipa.crt /tmp/acme-subca.pem +---- + +Install the signed certificate on the external system alongside the private +key. The system can now issue certificates signed by the sub-CA without any +further involvement from Dogtag. + +== Behaviour Differences from a Local-Key Sub-CA == + +[cols="1,1,1",options="header"] +|=== +|Property +|Local-key authority +|External-key authority + +|`externalKey` in GET response +|not present (false) +|`true` + +|`ready` in GET response +|`true` +|`false` + +|Dogtag can issue certs via this CA +|yes +|no + +|`authorityKeyNickname` in LDAP +|`:` +|`#external#:` (sentinel) + +|Key retriever / replication +|enabled +|not started +|=== + +== Deleting an External-Key Authority == + +External-key authorities are deleted the same way as local-key authorities: + +[source,bash] +---- +$ pki -n caadmin ca-authority-del +---- + +Deleting the Dogtag authority record does not revoke the sub-CA certificate. +Revoke the certificate separately if required before deleting the authority. + +== See Also == + +* link:https://github.com/dogtagpki/pki/wiki/PKI-CA-Authority-CLI[PKI CA Authority CLI] +* link:https://github.com/dogtagpki/pki/wiki/CA-Authority-REST-API[CA Authority REST API] From d6c8e2e9f6a60a473dce8c1d8592bb67dc1879a1 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 13 Apr 2026 11:07:40 +0300 Subject: [PATCH 05/15] tests: add unit tests for AuthorityRecord.isExternalKey() Cover the sentinel prefix detection introduced for externally-keyed lightweight CAs: true for the #external#: prefix, false for real token:nickname strings, false for null, and a constant-value check to guard against the prefix losing its leading '#'. Fixes: https://github.com/dogtagpki/pki/issues/5336 Signed-off-by: Alexander Bokovoy --- .../server/ca/AuthorityRecordTest.java | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 base/ca/src/test/java/org/dogtagpki/server/ca/AuthorityRecordTest.java diff --git a/base/ca/src/test/java/org/dogtagpki/server/ca/AuthorityRecordTest.java b/base/ca/src/test/java/org/dogtagpki/server/ca/AuthorityRecordTest.java new file mode 100644 index 00000000000..e7977064bcb --- /dev/null +++ b/base/ca/src/test/java/org/dogtagpki/server/ca/AuthorityRecordTest.java @@ -0,0 +1,68 @@ +// Copyright Red Hat, Inc. +// +// SPDX-License-Identifier: GPL-2.0-or-later +// +package org.dogtagpki.server.ca; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class AuthorityRecordTest { + + private AuthorityRecord record; + + @BeforeEach + public void setUp() { + record = new AuthorityRecord(); + } + + @Test + public void testIsExternalKey_withSentinelPrefix() { + record.setKeyNickname( + AuthorityRecord.EXTERNAL_KEY_NICKNAME_PREFIX + "abc-123"); + assertTrue(record.isExternalKey(), + "Expected isExternalKey() == true for sentinel nickname"); + } + + @Test + public void testIsExternalKey_withNormalNickname() { + record.setKeyNickname("HSM:CA signing cert"); + assertFalse(record.isExternalKey(), + "Expected isExternalKey() == false for a real token:nickname"); + } + + @Test + public void testIsExternalKey_withInternalSoftwareNickname() { + record.setKeyNickname("caSigningCert cert-pki-ca"); + assertFalse(record.isExternalKey(), + "Expected isExternalKey() == false for a software-token nickname"); + } + + @Test + public void testIsExternalKey_withNullNickname() { + record.setKeyNickname(null); + assertFalse(record.isExternalKey(), + "Expected isExternalKey() == false when keyNickname is null"); + } + + @Test + public void testIsExternalKey_sentinelPrefixConstantValue() { + // The constant must start with '#' so it can never be mistaken for a + // valid NSS token name (which uses alphanumerics and spaces only). + assertTrue(AuthorityRecord.EXTERNAL_KEY_NICKNAME_PREFIX.startsWith("#"), + "Sentinel prefix must start with '#'"); + } + + @Test + public void testIsExternalKey_prefixAloneIsExternal() { + // An edge case: just the prefix with no UUID suffix should still be + // detected as external so it does not accidentally reach the NSS token + // lookup code. + record.setKeyNickname(AuthorityRecord.EXTERNAL_KEY_NICKNAME_PREFIX); + assertTrue(record.isExternalKey(), + "Bare sentinel prefix should still be detected as external"); + } +} From ae6e18a1f680555e09a62cd39fe33f862ac2f3fc Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 13 Apr 2026 11:26:19 +0300 Subject: [PATCH 06/15] tests: add GHA integration test for external-key CA authority creation Fixes: https://github.com/dogtagpki/pki/issues/5336 Signed-off-by: Alexander Bokovoy --- .../ca-authority-external-key-test.yml | 273 ++++++++++++++++++ .github/workflows/subca-tests.yml | 5 + 2 files changed, 278 insertions(+) create mode 100644 .github/workflows/ca-authority-external-key-test.yml diff --git a/.github/workflows/ca-authority-external-key-test.yml b/.github/workflows/ca-authority-external-key-test.yml new file mode 100644 index 00000000000..c536c6cb17d --- /dev/null +++ b/.github/workflows/ca-authority-external-key-test.yml @@ -0,0 +1,273 @@ +name: CA authority with external key +# Tests the full lifecycle of a lightweight CA authority whose private key +# is held externally. Simulates an ACME server that generates its own CA +# key pair in an HSM by using OpenSSL to create the key and CSR locally, +# then submitting only the CSR to Dogtag. +# +# Verifies: +# - POST /v2/authorities with csrData returns externalKey=true, ready=false +# - csrData is not stored or echoed back in subsequent GET responses +# - The signed sub-CA certificate chains to the root CA +# - The signed certificate's public key matches the submitted CSR +# - The signed certificate has CA:TRUE in Basic Constraints +# - The authority can be deleted with pki ca-authority-del + +on: workflow_call + +env: + DS_IMAGE: ${{ vars.DS_IMAGE || 'quay.io/389ds/dirsrv' }} + +jobs: + test: + name: Test + runs-on: ubuntu-latest + env: + SHARED: /tmp/workdir/pki + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Retrieve PKI images + uses: actions/cache@v4 + with: + key: pki-images-${{ github.sha }} + path: pki-images.tar + + - name: Load PKI images + run: docker load --input pki-images.tar + + - name: Create network + run: docker network create example + + - name: Set up DS container + run: | + tests/bin/ds-create.sh \ + --image=${{ env.DS_IMAGE }} \ + --hostname=ds.example.com \ + --network=example \ + --network-alias=ds.example.com \ + --password=Secret.123 \ + ds + + - name: Set up PKI container + run: | + tests/bin/runner-init.sh \ + --hostname=pki.example.com \ + --network=example \ + --network-alias=pki.example.com \ + pki + + - name: Install CA + run: | + docker exec pki pkispawn \ + -f /usr/share/pki/server/examples/installation/ca.cfg \ + -s CA \ + -D pki_ds_url=ldap://ds.example.com:3389 \ + -v + + - name: Install CA signing cert + run: | + docker exec pki pki-server cert-export \ + --cert-file ca_signing.crt \ + ca_signing + + docker exec pki pki nss-cert-import \ + --cert ca_signing.crt \ + --trust CT,C,C \ + ca_signing + + - name: Install CA admin cert + run: | + docker exec pki pki pkcs12-import \ + --pkcs12 /root/.dogtag/pki-tomcat/ca_admin_cert.p12 \ + --pkcs12-password Secret.123 + + - name: Generate external CA key pair and CSR with OpenSSL + run: | + # Simulate an ACME server generating its own CA key pair in an HSM. + # In production the private key would never leave the HSM; here we + # use a plain file to keep the test self-contained. + docker exec pki openssl req \ + -newkey rsa:2048 \ + -nodes \ + -keyout /tmp/external-ca.key \ + -subj "/CN=Test External Sub-CA/O=EXAMPLE" \ + -out /tmp/external-ca.csr + + docker exec pki openssl req -text -noout -in /tmp/external-ca.csr + + - name: Get host CA parent ID + run: | + docker exec pki python3 -c " + import subprocess, re + out = subprocess.check_output(['pki', 'ca-authority-find']).decode() + host_auth = False + for line in out.splitlines(): + if 'Host authority' in line: + host_auth = True + if host_auth: + m = re.search(r'ID:\s+(\S+)', line) + if m: + with open('/tmp/parent-id', 'w', encoding='utf-8') as f: + f.write(m.group(1)) + break + " | tee /tmp/parent-id + + - name: Build authority creation JSON payload + run: | + docker exec pki python3 -c " + import json + csr = open('/tmp/external-ca.csr').read() + parent_id = open('/tmp/parent-id').read() + payload = { + 'dn': 'CN=Test External Sub-CA,O=EXAMPLE', + 'parentID': parent_id, + 'description': 'Integration test external sub-CA', + 'csrData': csr, + } + open('/tmp/authority-req.json', 'w').write(json.dumps(payload)) + " + + - name: Create external-key authority via REST API + run: | + docker exec pki curl -sk -X POST \ + --cert-type P12 \ + --cert /root/.dogtag/pki-tomcat/ca_admin_cert.p12:Secret.123 \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d @/tmp/authority-req.json \ + -o /tmp/authority-resp.json \ + https://pki.example.com:8443/ca/v2/authorities + + docker exec pki python3 -m json.tool /tmp/authority-resp.json + + - name: Verify authority creation response + run: | + docker exec pki python3 -c " + import json, sys + data = json.load(open('/tmp/authority-resp.json')) + errors = [] + if data.get('externalKey') != True: + errors.append('Expected externalKey=true, got: {}'.format(data.get('externalKey'))) + if data.get('ready') != False: + errors.append('Expected ready=false, got: {}'.format(data.get('ready'))) + if 'csrData' in data: + errors.append('csrData must not be stored or returned in the response') + if 'profileId' in data: + errors.append('profileId must not be stored or returned in the response') + if errors: + print('FAILED:', errors, file=sys.stderr) + sys.exit(1) + open('/tmp/authority-id', 'w').write(data['id']) + print('OK: externalKey=true, ready=false, csrData absent, profileId absent') + print('Authority ID:', data['id']) + " + + - name: Retrieve signed sub-CA certificate + run: | + AID=$(docker exec pki cat /tmp/authority-id) + + docker exec pki curl -sk \ + -H "Accept: application/x-pem-file" \ + https://pki.example.com:8443/ca/v2/authorities/${AID}/cert \ + -o /tmp/external-subca.crt + + docker exec pki openssl x509 -text -noout -in /tmp/external-subca.crt + + - name: Verify certificate subject DN + run: | + docker exec pki openssl x509 -noout -subject -in /tmp/external-subca.crt \ + | tee actual + + echo "subject=CN=Test External Sub-CA, O=EXAMPLE" > expected + diff expected actual + + - name: Verify certificate has CA:TRUE and pathLen=0 basic constraint + run: | + docker exec pki openssl x509 -noout -text -in /tmp/external-subca.crt \ + | grep -q "CA:TRUE" + + docker exec pki openssl x509 -noout -text -in /tmp/external-subca.crt \ + | grep -q "pathlen:0" + + - name: Verify certificate public key matches the submitted CSR + run: | + docker exec pki python3 -c " + import subprocess, sys + + def pubkey_sha256(cmd): + pub = subprocess.check_output(cmd) + der = subprocess.check_output( + ['openssl', 'pkey', '-pubin', '-outform', 'DER'], input=pub) + return subprocess.check_output(['sha256sum'], input=der).split()[0] + + cert_fp = pubkey_sha256( + ['openssl', 'x509', '-noout', '-pubkey', '-in', '/tmp/external-subca.crt']) + csr_fp = pubkey_sha256( + ['openssl', 'req', '-noout', '-pubkey', '-in', '/tmp/external-ca.csr']) + + if cert_fp != csr_fp: + print('Public key mismatch: cert={} csr={}'.format(cert_fp, csr_fp), + file=sys.stderr) + sys.exit(1) + print('OK: public key in certificate matches submitted CSR') + " + + - name: Verify certificate chains to root CA + run: | + docker exec pki openssl verify \ + -CAfile ca_signing.crt \ + /tmp/external-subca.crt + + - name: Verify GET reports externalKey=true, ready=false + run: | + AID=$(docker exec pki cat /tmp/authority-id) + + docker exec pki python3 -c " + import json, subprocess, sys + out = subprocess.check_output([ + 'curl', '-sk', + '-H', 'Accept: application/json', + 'https://pki.example.com:8443/ca/v2/authorities/$AID', + ]) + data = json.loads(out) + errors = [] + if data.get('externalKey') != True: + errors.append('Expected externalKey=true, got: {}'.format(data.get('externalKey'))) + if data.get('ready') != False: + errors.append('Expected ready=false, got: {}'.format(data.get('ready'))) + if errors: + print('FAILED:', errors, file=sys.stderr) + sys.exit(1) + print('OK: GET confirms externalKey=true, ready=false') + " + + - name: Delete external-key authority + run: | + AID=$(docker exec pki cat /tmp/authority-id) + docker exec pki pki -n caadmin ca-authority-del ${AID} + + - name: Verify authority no longer exists + run: | + AID=$(docker exec pki cat /tmp/authority-id) + docker exec pki pki ca-authority-show ${AID} && exit 1 || true + + - name: Check DS server systemd journal + if: always() + run: | + docker exec ds journalctl -x --no-pager -u dirsrv@localhost.service + + - name: Check DS container logs + if: always() + run: | + docker logs ds + + - name: Check PKI server systemd journal + if: always() + run: | + docker exec pki journalctl -x --no-pager -u pki-tomcatd@pki-tomcat.service + + - name: Check CA debug log + if: always() + run: | + docker exec pki find /var/lib/pki/pki-tomcat/logs/ca -name "debug.*" -exec cat {} \; diff --git a/.github/workflows/subca-tests.yml b/.github/workflows/subca-tests.yml index 6b2d4b5d794..48badf25464 100644 --- a/.github/workflows/subca-tests.yml +++ b/.github/workflows/subca-tests.yml @@ -52,3 +52,8 @@ jobs: name: LWCA clone with HSM needs: build uses: ./.github/workflows/lwca-clone-hsm-test.yml + + lwca-authority-external-key-test: + name: LWCA authority with external key + needs: build + uses: ./.github/workflows/ca-authority-external-key-test.yml From 08bf2dd3a2dd744a75feea5d3b5bfd5379258164 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 13 Apr 2026 12:49:48 +0300 Subject: [PATCH 07/15] tools: add --csr-file and --profile options to ca-authority-create Extend pki ca-authority-create with two new options: --csr-file Read a PEM-encoded PKCS#10 CSR from the given file and submit it as the csrData field. When present, Dogtag signs the CSR without generating a local key pair; the authority's private key remains with the caller (e.g. in an HSM). --profile Select the signing profile for the sub-CA certificate. Valid for both local-key and external-key creation: - with --csr-file: defaults to caExternalKeyCACert - without --csr-file: defaults to caCACert AuthorityCLI.printAuthorityData() is extended to print "External key: true" when the returned authority has externalKey set. CAEngine.generateSigningCert() is updated to accept a profileId parameter so that local-key sub-CA creation goes through the same profile-selection path as the external-key path. Fixes: https://github.com/dogtagpki/pki/issues/5336 Signed-off-by: Alexander Bokovoy --- .../org/dogtagpki/server/ca/CAEngine.java | 17 +++++++--- .../cmstools/authority/AuthorityCLI.java | 3 ++ .../authority/AuthorityCreateCLI.java | 31 +++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/base/ca/src/main/java/org/dogtagpki/server/ca/CAEngine.java b/base/ca/src/main/java/org/dogtagpki/server/ca/CAEngine.java index 8530bed7ee7..8e94b0f36e1 100644 --- a/base/ca/src/main/java/org/dogtagpki/server/ca/CAEngine.java +++ b/base/ca/src/main/java/org/dogtagpki/server/ca/CAEngine.java @@ -1269,12 +1269,13 @@ public X509CertImpl generateSigningCert( CertificateAuthority ca, X500Name subjectX500Name, AuthToken authToken, - CryptoToken token) + CryptoToken token, + String profileId) throws Exception { KeyPair keypair = ca.generateKeyPair(token); PKCS10 pkcs10 = ca.generateCertRequest(keypair, subjectX500Name); - return generateSigningCertFromCSR(ca, pkcs10, authToken, "caCACert"); + return generateSigningCertFromCSR(ca, pkcs10, authToken, profileId); } /** @@ -1491,8 +1492,16 @@ public AuthorityRecord createAuthorityRecord( CryptoToken token = CryptoUtil.getKeyStorageToken(tokenname); - logger.info("CAEngine: Generating signing certificate"); - cert = generateSigningCert(parentCA, subjectX500Name, authToken, token); + // Use the caller-supplied profile or fall back to the + // established default for local-key sub-CA issuance. + String effectiveProfileId = (profileId != null && !profileId.isBlank()) + ? profileId + : "caCACert"; + + logger.info("CAEngine: Generating signing certificate with profile '{}'", + effectiveProfileId); + cert = generateSigningCert(parentCA, subjectX500Name, authToken, token, + effectiveProfileId); logger.info("CAEngine: Importing " + nickname + " cert into " + token.getName()); CryptoStore store = token.getCryptoStore(); diff --git a/base/tools/src/main/java/com/netscape/cmstools/authority/AuthorityCLI.java b/base/tools/src/main/java/com/netscape/cmstools/authority/AuthorityCLI.java index 59f7d72dc1c..08c655d465d 100644 --- a/base/tools/src/main/java/com/netscape/cmstools/authority/AuthorityCLI.java +++ b/base/tools/src/main/java/com/netscape/cmstools/authority/AuthorityCLI.java @@ -55,6 +55,9 @@ protected static void printAuthorityData(AuthorityData data) { System.out.println(" Enabled: " + data.getEnabled()); System.out.println(" Ready to sign: " + data.getReady()); + Boolean externalKey = data.getExternalKey(); + if (externalKey != null && externalKey) + System.out.println(" External key: true"); String desc = data.getDescription(); if (desc != null) System.out.println(" Description: " + desc); diff --git a/base/tools/src/main/java/com/netscape/cmstools/authority/AuthorityCreateCLI.java b/base/tools/src/main/java/com/netscape/cmstools/authority/AuthorityCreateCLI.java index 66e5ffaf6d9..2a5a3aa2198 100644 --- a/base/tools/src/main/java/com/netscape/cmstools/authority/AuthorityCreateCLI.java +++ b/base/tools/src/main/java/com/netscape/cmstools/authority/AuthorityCreateCLI.java @@ -1,5 +1,9 @@ package com.netscape.cmstools.authority; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; + import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.Option; @@ -32,6 +36,19 @@ public void createOptions() { Option optDesc = new Option(null, "desc", true, "Optional description"); optDesc.setArgName("string"); options.addOption(optDesc); + + Option optCsrFile = new Option(null, "csr-file", true, + "PEM file containing a CSR for an externally-held CA key. " + + "When provided, Dogtag signs the CSR as a sub-CA certificate " + + "without generating a local key pair."); + optCsrFile.setArgName("path"); + options.addOption(optCsrFile); + + Option optProfile = new Option(null, "profile", true, + "Signing profile for the sub-CA certificate " + + "(default: caExternalKeyCACert with --csr-file, caCACert otherwise)"); + optProfile.setArgName("id"); + options.addOption(optProfile); } @Override @@ -67,6 +84,16 @@ public void execute(CommandLine cmd) throws Exception { if (cmd.hasOption("desc")) desc = cmd.getOptionValue("desc"); + String csrData = null; + if (cmd.hasOption("csr-file")) { + String csrFile = cmd.getOptionValue("csr-file"); + csrData = new String(Files.readAllBytes(Paths.get(csrFile)), StandardCharsets.UTF_8); + } + + String profileId = null; + if (cmd.hasOption("profile")) + profileId = cmd.getOptionValue("profile"); + String dn = cmdArgs[0]; MainCLI mainCLI = (MainCLI) getRoot(); @@ -74,6 +101,10 @@ public void execute(CommandLine cmd) throws Exception { AuthorityData data = new AuthorityData( null, dn, null, parentAIDString, null, null, true /* enabled */, desc, null); + if (csrData != null) + data.setCsrData(csrData); + if (profileId != null) + data.setProfileId(profileId); PKIClient client = getPKIClient(); SubsystemClient subsystemClient = getSubsystemClient(client); From ad7585624326a88dabbd56cf33906a781fe9a0e0 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 13 Apr 2026 13:04:27 +0300 Subject: [PATCH 08/15] python: add csr_data, profile_id, external_key, ready to AuthorityData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthorityData.json_attribute_names now maps the four new JSON fields: csrData → csr_data (input-only: PEM CSR for external-key CA creation) profileId → profile_id (input-only: signing profile override) externalKey → external_key (output: True when Dogtag does not hold the key) ready → ready (output: False for external-key authorities) The __init__ signature and __repr__ are updated accordingly. Callers can now pass csr_data and profile_id to AuthorityClient.create_ca() and inspect external_key / ready on the returned AuthorityData object. Fixes: https://github.com/dogtagpki/pki/issues/5336 Signed-off-by: Alexander Bokovoy --- base/common/python/pki/authority.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/base/common/python/pki/authority.py b/base/common/python/pki/authority.py index d70e586e4e7..f21e0ae53b1 100644 --- a/base/common/python/pki/authority.py +++ b/base/common/python/pki/authority.py @@ -44,12 +44,18 @@ class AuthorityData: 'enabled': 'enabled', 'isHostAuthority': 'is_host_authority', 'link': 'link', - 'parentID': 'parent_aid' + 'parentID': 'parent_aid', + 'csrData': 'csr_data', + 'profileId': 'profile_id', + 'externalKey': 'external_key', + 'ready': 'ready', } def __init__(self, dn=None, aid=None, parent_aid=None, description=None, enabled="False", - is_host_authority="False", link=None): + is_host_authority="False", link=None, + csr_data=None, profile_id=None, + external_key=None, ready=None): self.dn = dn self.aid = aid self.parent_aid = parent_aid @@ -57,6 +63,10 @@ def __init__(self, dn=None, aid=None, parent_aid=None, self.enabled = (enabled.lower() == "true") self.is_host_authority = (is_host_authority.lower() == "true") self.link = link + self.csr_data = csr_data + self.profile_id = profile_id + self.external_key = external_key + self.ready = ready def __repr__(self): attributes = { @@ -66,7 +76,9 @@ def __repr__(self): "description": self.description, "is_host_authority": self.is_host_authority, "parent_aid": self.parent_aid, - "enabled": self.enabled + "enabled": self.enabled, + "external_key": self.external_key, + "ready": self.ready, } } return str(attributes) From a559b5a24811247a6d4fba3fb05afe4a02c5c434 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 13 Apr 2026 13:11:58 +0300 Subject: [PATCH 09/15] docs: add Creating-Sub-CA guide covering --profile for local-key sub-CAs Add docs/user/tools/Creating-Sub-CA.adoc documenting the standard local-key lightweight CA creation workflow, including: - Obtaining the parent CA ID - Creating a sub-CA with pki ca-authority-create - Using --profile to select a signing profile (default: caCACert) - Common management operations (enable/disable/delete) Update Creating-Sub-CA-with-External-Key.adoc to cross-link the new document from its --profile NOTE and from the See Also section. Update API-Changes.adoc to reference both guides from the profileId API change description. Fixes: https://github.com/dogtagpki/pki/issues/5336 Signed-off-by: Alexander Bokovoy --- docs/changes/v11.10.0/API-Changes.adoc | 4 +- .../Creating-Sub-CA-with-External-Key.adoc | 5 +- docs/user/tools/Creating-Sub-CA.adoc | 128 ++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 docs/user/tools/Creating-Sub-CA.adoc diff --git a/docs/changes/v11.10.0/API-Changes.adoc b/docs/changes/v11.10.0/API-Changes.adoc index 9b814d9ff91..2fceb4f9cc1 100644 --- a/docs/changes/v11.10.0/API-Changes.adoc +++ b/docs/changes/v11.10.0/API-Changes.adoc @@ -26,7 +26,9 @@ Both `csrData` and `profileId` are consumed at creation time and are not stored or returned in subsequent GET responses. See link:../../user/tools/Creating-Sub-CA-with-External-Key.adoc[Creating a -Sub-CA with an Externally-Held Key] for usage details. +Sub-CA with an Externally-Held Key] and +link:../../user/tools/Creating-Sub-CA.adoc[Creating a Lightweight Sub-CA] +for usage details. == Remove KeyClient.archive_key() == diff --git a/docs/user/tools/Creating-Sub-CA-with-External-Key.adoc b/docs/user/tools/Creating-Sub-CA-with-External-Key.adoc index 5d04cf6d36c..dd216c2f030 100644 --- a/docs/user/tools/Creating-Sub-CA-with-External-Key.adoc +++ b/docs/user/tools/Creating-Sub-CA-with-External-Key.adoc @@ -105,7 +105,9 @@ they are not stored or retrievable after the authority is created. NOTE: `--profile` is not exclusive to external-key creation. It can also be used without `--csr-file` to select the signing profile for a -local-key sub-CA, in which case the default is `caCACert`. +local-key sub-CA, in which case the default is `caCACert`. See +link:Creating-Sub-CA.adoc[Creating a Lightweight Sub-CA] for the +standard local-key workflow. == Step 4: Retrieve the Signed Sub-CA Certificate == @@ -178,5 +180,6 @@ Revoke the certificate separately if required before deleting the authority. == See Also == +* link:Creating-Sub-CA.adoc[Creating a Lightweight Sub-CA] (standard local-key workflow) * link:https://github.com/dogtagpki/pki/wiki/PKI-CA-Authority-CLI[PKI CA Authority CLI] * link:https://github.com/dogtagpki/pki/wiki/CA-Authority-REST-API[CA Authority REST API] diff --git a/docs/user/tools/Creating-Sub-CA.adoc b/docs/user/tools/Creating-Sub-CA.adoc new file mode 100644 index 00000000000..f191addc288 --- /dev/null +++ b/docs/user/tools/Creating-Sub-CA.adoc @@ -0,0 +1,128 @@ += Creating a Lightweight Sub-CA = + +== Overview == + +Dogtag supports _lightweight CAs_ (also called authorities or sub-CAs): CA +instances that share a single Tomcat process with the host CA but each hold +their own signing key and certificate. Each lightweight CA can issue +end-entity certificates independently; the host CA signs the sub-CA +certificate at creation time. + +This document covers the standard case where Dogtag generates the CA key pair +on the same PKCS#11 token as the host CA. If you need the signing key to +remain in a remote HSM, see +link:Creating-Sub-CA-with-External-Key.adoc[Creating a Sub-CA with an +Externally-Held Key] instead. + +== Prerequisites == + +* A running Dogtag CA instance. +* An account with CA administrator privileges (`caadmin` or equivalent). + +== Step 1: Obtain the Parent CA ID == + +[source,bash] +---- +$ pki -n caadmin ca-authority-find +---- + +Note the `ID` of the authority that will be the parent of the new sub-CA. +For a typical single-CA deployment this is the host authority, whose +`isHostAuthority` field is `true`. + +== Step 2: Create the Sub-CA == + +[source,bash] +---- +PARENT_ID="" + +pki -n caadmin ca-authority-create \ + --parent "${PARENT_ID}" \ + --desc "My application sub-CA" \ + "CN=App Sub-CA,O=EXAMPLE.COM" +---- + +On success the command prints the new authority's details: + +[source] +---- + Authority DN: CN=App Sub-CA,O=EXAMPLE.COM + ID: 3f4a9c21-1b2e-4d57-a891-0c3e7b5d9f10 + Parent ID: 00000000-0000-0000-0000-000000000001 + Issuer DN: CN=Certificate Authority,O=EXAMPLE.COM + Serial no: 0xD + Enabled: true + Ready to sign: true +---- + +=== Selecting a Signing Profile === + +By default Dogtag signs the new sub-CA certificate using the `caCACert` +profile. Use `--profile` to choose a different profile: + +[source,bash] +---- +pki -n caadmin ca-authority-create \ + --parent "${PARENT_ID}" \ + --profile myCustomSubCAProfile \ + --desc "My application sub-CA" \ + "CN=App Sub-CA,O=EXAMPLE.COM" +---- + +The profile must be registered in the Dogtag instance. Use +`pki -n caadmin ca-profile-find` to list available profiles. The +`--profile` option and its argument are creation-time inputs only; they are +not stored or returned in subsequent `ca-authority-show` responses. + +NOTE: The `--profile` option applies to both local-key and external-key +sub-CA creation. For the external-key path the default profile is +`caExternalKeyCACert`; for local-key creation (this document) the default is +`caCACert`. + +== Step 3: Retrieve the Signed Sub-CA Certificate == + +[source,bash] +---- +AID="3f4a9c21-1b2e-4d57-a891-0c3e7b5d9f10" + +# PEM certificate only +pki -n caadmin ca-authority-show --output-format PEM "${AID}" + +# Alternatively, fetch the full chain via the REST API: +curl -s -H "Accept: application/x-pem-file" \ + https://ca.example.com:8443/ca/v2/authorities/${AID}/chain \ + -o /tmp/app-subca-chain.pem +---- + +== Managing Lightweight CAs == + +[cols="1,2",options="header"] +|=== +|Operation +|Command + +|List all authorities +|`pki -n caadmin ca-authority-find` + +|Show a specific authority +|`pki -n caadmin ca-authority-show ` + +|Enable an authority +|`pki -n caadmin ca-authority-enable ` + +|Disable an authority +|`pki -n caadmin ca-authority-disable ` + +|Delete an authority +|`pki -n caadmin ca-authority-del ` +|=== + +Deleting an authority record does not revoke its certificate. Revoke the +sub-CA certificate separately before deleting the record if you want to +prevent further trust in the CA. + +== See Also == + +* link:Creating-Sub-CA-with-External-Key.adoc[Creating a Sub-CA with an Externally-Held Key] +* link:https://github.com/dogtagpki/pki/wiki/PKI-CA-Authority-CLI[PKI CA Authority CLI] +* link:https://github.com/dogtagpki/pki/wiki/CA-Authority-REST-API[CA Authority REST API] From 4d0fac051a569ae02e7d368519d46299c6c06f7d Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 13 Apr 2026 16:36:14 +0300 Subject: [PATCH 10/15] ci: fix authority external-key test to use pki CLI for creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit curl --cert-type P12 does not authenticate to Tomcat's TLS client certificate layer; the user principal is never set, so ACLFilter returns 403 "No user principal provided." for the POST /v2/authorities endpoint. Replace the curl-based creation step with pki -n caadmin ca-authority-create --csr-file, which authenticates via the NSS database imported in the "Install CA admin cert" step — consistent with how all other LWCA tests authenticate. Verification of the response fields (externalKey, ready, csrData absent, profileId absent) is moved to the unauthenticated GET /v2/authorities/ step, which already worked correctly. Fixes: https://github.com/dogtagpki/pki/issues/5336 Signed-off-by: Alexander Bokovoy --- .../ca-authority-external-key-test.yml | 136 +++++++++++------- 1 file changed, 83 insertions(+), 53 deletions(-) diff --git a/.github/workflows/ca-authority-external-key-test.yml b/.github/workflows/ca-authority-external-key-test.yml index c536c6cb17d..f553b76e7b4 100644 --- a/.github/workflows/ca-authority-external-key-test.yml +++ b/.github/workflows/ca-authority-external-key-test.yml @@ -5,11 +5,12 @@ name: CA authority with external key # then submitting only the CSR to Dogtag. # # Verifies: -# - POST /v2/authorities with csrData returns externalKey=true, ready=false -# - csrData is not stored or echoed back in subsequent GET responses +# - pki ca-authority-create --csr-file reports External key: true, Ready to sign: false +# - GET /v2/authorities/ returns externalKey=true, ready=false +# - csrData and profileId are not stored or returned in GET responses # - The signed sub-CA certificate chains to the root CA # - The signed certificate's public key matches the submitted CSR -# - The signed certificate has CA:TRUE in Basic Constraints +# - The signed certificate has CA:TRUE and pathLen:0 in Basic Constraints # - The authority can be deleted with pki ca-authority-del on: workflow_call @@ -87,11 +88,19 @@ jobs: # Simulate an ACME server generating its own CA key pair in an HSM. # In production the private key would never leave the HSM; here we # use a plain file to keep the test self-contained. + # Use X.500 attribute order (general→specific: O before CN) so that + # the DER RDN sequence matches what JSS produces when it parses the + # RFC 2253 string "CN=Test External Sub-CA,O=EXAMPLE" passed to + # ca-authority-create. JSS treats the leftmost attribute in an + # RFC 2253 string as most-specific (last in DER), so the DER + # sequence becomes [O, CN]. OpenSSL encodes -subj left-to-right, + # so "/O=EXAMPLE/CN=..." also produces DER [O, CN]. The byte-level + # X500Name.equals() check in CAEngine therefore sees identical DER. docker exec pki openssl req \ -newkey rsa:2048 \ -nodes \ -keyout /tmp/external-ca.key \ - -subj "/CN=Test External Sub-CA/O=EXAMPLE" \ + -subj "/O=EXAMPLE/CN=Test External Sub-CA" \ -out /tmp/external-ca.csr docker exec pki openssl req -text -noout -in /tmp/external-ca.csr @@ -113,54 +122,66 @@ jobs: break " | tee /tmp/parent-id - - name: Build authority creation JSON payload + - name: Verify caExternalKeyCACert profile is loaded run: | - docker exec pki python3 -c " - import json - csr = open('/tmp/external-ca.csr').read() - parent_id = open('/tmp/parent-id').read() - payload = { - 'dn': 'CN=Test External Sub-CA,O=EXAMPLE', - 'parentID': parent_id, - 'description': 'Integration test external sub-CA', - 'csrData': csr, - } - open('/tmp/authority-req.json', 'w').write(json.dumps(payload)) + # The profile must be in profile.list in CS.cfg and its .cfg file must + # be present in the instance profiles directory for CA startup to load it. + # If this step fails, check the CA debug log for "Unable to create profile". + docker exec pki grep -q "caExternalKeyCACert" \ + /var/lib/pki/pki-tomcat/ca/conf/CS.cfg + docker exec pki test -f \ + /var/lib/pki/pki-tomcat/ca/profiles/ca/caExternalKeyCACert.cfg + echo "OK: caExternalKeyCACert is registered in CS.cfg and profile file is present" + + # Verify CA startup logged successful profile initialisation (not a silent failure). + docker exec pki bash -c " + if find /var/lib/pki/pki-tomcat/logs/ca -name 'debug.*' \ + -exec grep -l 'Unable to create profile.*caExternalKeyCACert' {} + 2>/dev/null \ + | grep -q .; then + echo 'ERROR: CA debug log shows caExternalKeyCACert failed to initialize' >&2 + find /var/lib/pki/pki-tomcat/logs/ca -name 'debug.*' \ + -exec grep 'caExternalKeyCACert' {} + + exit 1 + fi + echo 'OK: no initialization failure logged for caExternalKeyCACert' " - - name: Create external-key authority via REST API + - name: Create external-key authority via pki CLI run: | - docker exec pki curl -sk -X POST \ - --cert-type P12 \ - --cert /root/.dogtag/pki-tomcat/ca_admin_cert.p12:Secret.123 \ - -H "Content-Type: application/json" \ - -H "Accept: application/json" \ - -d @/tmp/authority-req.json \ - -o /tmp/authority-resp.json \ - https://pki.example.com:8443/ca/v2/authorities - - docker exec pki python3 -m json.tool /tmp/authority-resp.json + PARENT_ID=$(docker exec pki cat /tmp/parent-id) + + # pki CLI uses the NSS database (imported above) for TLS client cert + # auth; curl --cert-type P12 does not work for Tomcat cert auth. + docker exec pki bash -c " + pki -n caadmin ca-authority-create \ + --parent ${PARENT_ID} \ + --csr-file /tmp/external-ca.csr \ + --desc 'Integration test external sub-CA' \ + 'CN=Test External Sub-CA,O=EXAMPLE' \ + | tee /tmp/authority-create-output.txt + " - - name: Verify authority creation response + - name: Verify authority creation output and extract ID run: | docker exec pki python3 -c " - import json, sys - data = json.load(open('/tmp/authority-resp.json')) + import re, sys + text = open('/tmp/authority-create-output.txt').read() + print(text) errors = [] - if data.get('externalKey') != True: - errors.append('Expected externalKey=true, got: {}'.format(data.get('externalKey'))) - if data.get('ready') != False: - errors.append('Expected ready=false, got: {}'.format(data.get('ready'))) - if 'csrData' in data: - errors.append('csrData must not be stored or returned in the response') - if 'profileId' in data: - errors.append('profileId must not be stored or returned in the response') + m = re.search(r'^\s+ID:\s+(\S+)', text, re.MULTILINE) + if not m: + print('ERROR: could not find authority ID in output', file=sys.stderr) + sys.exit(1) + open('/tmp/authority-id', 'w').write(m.group(1)) + print('Authority ID:', m.group(1)) + if not re.search(r'External key:\s+true', text): + errors.append('Expected \"External key: true\" in CLI output') + if not re.search(r'Ready to sign:\s+false', text): + errors.append('Expected \"Ready to sign: false\" in CLI output') if errors: print('FAILED:', errors, file=sys.stderr) sys.exit(1) - open('/tmp/authority-id', 'w').write(data['id']) - print('OK: externalKey=true, ready=false, csrData absent, profileId absent') - print('Authority ID:', data['id']) + print('OK: External key=true, Ready to sign=false') " - name: Retrieve signed sub-CA certificate @@ -176,10 +197,13 @@ jobs: - name: Verify certificate subject DN run: | - docker exec pki openssl x509 -noout -subject -in /tmp/external-subca.crt \ - | tee actual + # Use -nameopt RFC2253 for a consistent format across OpenSSL versions. + # RFC 2253 lists attributes most-specific first (CN before O), which + # matches the string passed to ca-authority-create. + docker exec pki openssl x509 -noout -subject -nameopt RFC2253 \ + -in /tmp/external-subca.crt | tee actual - echo "subject=CN=Test External Sub-CA, O=EXAMPLE" > expected + echo "subject=CN=Test External Sub-CA,O=EXAMPLE" > expected diff expected actual - name: Verify certificate has CA:TRUE and pathLen=0 basic constraint @@ -219,27 +243,33 @@ jobs: -CAfile ca_signing.crt \ /tmp/external-subca.crt - - name: Verify GET reports externalKey=true, ready=false + - name: Verify GET reports externalKey=true, ready=false, no stored csrData/profileId run: | AID=$(docker exec pki cat /tmp/authority-id) + docker exec pki curl -sk \ + -H "Accept: application/json" \ + https://pki.example.com:8443/ca/v2/authorities/${AID} \ + -o /tmp/authority-get-resp.json + + docker exec pki python3 -m json.tool /tmp/authority-get-resp.json + docker exec pki python3 -c " - import json, subprocess, sys - out = subprocess.check_output([ - 'curl', '-sk', - '-H', 'Accept: application/json', - 'https://pki.example.com:8443/ca/v2/authorities/$AID', - ]) - data = json.loads(out) + import json, sys + data = json.load(open('/tmp/authority-get-resp.json')) errors = [] if data.get('externalKey') != True: errors.append('Expected externalKey=true, got: {}'.format(data.get('externalKey'))) if data.get('ready') != False: errors.append('Expected ready=false, got: {}'.format(data.get('ready'))) + if 'csrData' in data: + errors.append('csrData must not be stored or returned in GET response') + if 'profileId' in data: + errors.append('profileId must not be stored or returned in GET response') if errors: print('FAILED:', errors, file=sys.stderr) sys.exit(1) - print('OK: GET confirms externalKey=true, ready=false') + print('OK: GET confirms externalKey=true, ready=false, csrData absent, profileId absent') " - name: Delete external-key authority From d0e30912121c094a00521a257d4cf85df3714baa Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 13 Apr 2026 20:33:03 +0300 Subject: [PATCH 11/15] ca: serve cert for external-key authority from certificate repository GET /ca/v2/authorities/{id}/cert returned 404 for external-key authorities because getCaX509Cert() returns null when the signing unit is not initialised (the private key lives outside Dogtag). The signed sub-CA certificate is stored in the certificate repository by the enrollment processor and its serial number is recorded in the authority LDAP record (authoritySerial). When getCaX509Cert() returns null and a serial number is available, fall back to certRepository.getX509Certificate(serial) to retrieve and return the certificate. Signed-off-by: Alexander Bokovoy --- .../ca/rest/base/AuthorityRepository.java | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/base/ca/src/main/java/org/dogtagpki/server/ca/rest/base/AuthorityRepository.java b/base/ca/src/main/java/org/dogtagpki/server/ca/rest/base/AuthorityRepository.java index 4c245ac7d16..2297f027225 100644 --- a/base/ca/src/main/java/org/dogtagpki/server/ca/rest/base/AuthorityRepository.java +++ b/base/ca/src/main/java/org/dogtagpki/server/ca/rest/base/AuthorityRepository.java @@ -519,9 +519,32 @@ public byte[] getBinaryCert(String authId) { throw new ResourceNotFoundException("CA \"" + authId + "\" not found"); org.mozilla.jss.crypto.X509Certificate cert = ca.getCaX509Cert(); - if (cert == null) + if (cert == null) { + // For external-key authorities the signing unit is not initialised + // (the private key lives outside Dogtag). Fall back to the + // certificate repository using the serial number stored in the + // authority LDAP record. + BigInteger serial = ca.getAuthoritySerial(); + if (serial != null) { + try { + X509CertImpl certImpl = ca.getCertRepository() + .getX509Certificate(serial); + if (certImpl != null) { + logger.info("AuthorityRepository: Returning cert for" + + " external-key authority {} from repository" + + " (serial 0x{})", authId, + serial.toString(16)); + return certImpl.getEncoded(); + } + } catch (Exception e) { + logger.warn("AuthorityRepository: Failed to retrieve cert" + + " for authority {} from repository: {}", + authId, e.getMessage()); + } + } throw new ResourceNotFoundException( "Certificate for CA \"" + authId + "\" not available"); + } try { return cert.getEncoded(); From c80ebc895f891c965a009a929ac0d32170baaa59 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Tue, 14 Apr 2026 06:07:37 +0300 Subject: [PATCH 12/15] tools: fix NullPointerException in ca-authority-del confirmation prompt readLine() returns null when stdin is closed or the command runs in a non-interactive context (pipeline, CI). The subsequent line.equalsIgnoreCase("Y") call then throws a NullPointerException. Treat null the same as "N": abort the deletion when no confirmation can be read, so the --force flag remains the only way to delete without an interactive prompt. Signed-off-by: Alexander Bokovoy --- .../com/netscape/cmstools/authority/AuthorityRemoveCLI.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base/tools/src/main/java/com/netscape/cmstools/authority/AuthorityRemoveCLI.java b/base/tools/src/main/java/com/netscape/cmstools/authority/AuthorityRemoveCLI.java index fcf9aa26b82..71ed39f9ca8 100644 --- a/base/tools/src/main/java/com/netscape/cmstools/authority/AuthorityRemoveCLI.java +++ b/base/tools/src/main/java/com/netscape/cmstools/authority/AuthorityRemoveCLI.java @@ -51,7 +51,7 @@ public void execute(CommandLine cmd) throws Exception { BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); String line = reader.readLine(); - if (!line.equalsIgnoreCase("Y")) { + if (line == null || !line.equalsIgnoreCase("Y")) { return; } } From 15eb27672f99ba294176e35d390b590e2dccd28e Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Tue, 14 Apr 2026 06:07:42 +0300 Subject: [PATCH 13/15] ci: pipe confirmation to ca-authority-del in external-key authority test Use 'echo Y | docker exec -i' so that the delete command receives the confirmation via stdin rather than running non-interactively with a closed stdin. This exercises the fixed null-check code path in AuthorityRemoveCLI and avoids the --force flag, keeping the test consistent with real administrative usage. Signed-off-by: Alexander Bokovoy --- .github/workflows/ca-authority-external-key-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ca-authority-external-key-test.yml b/.github/workflows/ca-authority-external-key-test.yml index f553b76e7b4..9ef9c7c527e 100644 --- a/.github/workflows/ca-authority-external-key-test.yml +++ b/.github/workflows/ca-authority-external-key-test.yml @@ -275,7 +275,7 @@ jobs: - name: Delete external-key authority run: | AID=$(docker exec pki cat /tmp/authority-id) - docker exec pki pki -n caadmin ca-authority-del ${AID} + echo Y | docker exec -i pki pki -n caadmin ca-authority-del ${AID} - name: Verify authority no longer exists run: | From 34cd0c64dde0aba73072ac903447a577a1bbd71a Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Tue, 14 Apr 2026 08:30:09 +0300 Subject: [PATCH 14/15] ci: disable authority before deletion in external-key authority test Dogtag requires a CA to be explicitly disabled before it can be deleted (CAEngine rejects the delete with CAEnabledException if the authority is still enabled). Add a ca-authority-disable step before ca-authority-del to satisfy this precondition and exercise the full disable-then-delete lifecycle. Signed-off-by: Alexander Bokovoy --- .github/workflows/ca-authority-external-key-test.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ca-authority-external-key-test.yml b/.github/workflows/ca-authority-external-key-test.yml index 9ef9c7c527e..6be90dfd667 100644 --- a/.github/workflows/ca-authority-external-key-test.yml +++ b/.github/workflows/ca-authority-external-key-test.yml @@ -11,7 +11,7 @@ name: CA authority with external key # - The signed sub-CA certificate chains to the root CA # - The signed certificate's public key matches the submitted CSR # - The signed certificate has CA:TRUE and pathLen:0 in Basic Constraints -# - The authority can be deleted with pki ca-authority-del +# - The authority can be disabled and then deleted with pki ca-authority-del on: workflow_call @@ -272,6 +272,11 @@ jobs: print('OK: GET confirms externalKey=true, ready=false, csrData absent, profileId absent') " + - name: Disable external-key authority + run: | + AID=$(docker exec pki cat /tmp/authority-id) + docker exec pki pki -n caadmin ca-authority-disable ${AID} + - name: Delete external-key authority run: | AID=$(docker exec pki cat /tmp/authority-id) From ff3d2082c0afa85bfc207e204df1ca5638aed955 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Tue, 14 Apr 2026 10:03:50 +0300 Subject: [PATCH 15/15] ca: fix getCACert() for external-key authorities getCACert() unconditionally dereferenced mSigningUnit, which is null for external-key authorities (initSigningUnits() returns early on the #external#: sentinel). The resulting NullPointerException was not caught by the EBaseException handlers in callers such as AuthorityRepository.readAuthorityData(), causing ca-authority-disable (and any other operation that calls getCACert() after modify) to fail with a blank PKIException. Apply the same repository fallback already used by getBinaryCert(): when mSigningUnit is null and authoritySerial is known, retrieve the X509CertImpl directly from certRepository. This makes getCACert() work correctly for external-key CAs everywhere it is called. Signed-off-by: Alexander Bokovoy --- .../com/netscape/ca/CertificateAuthority.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/base/ca/src/main/java/com/netscape/ca/CertificateAuthority.java b/base/ca/src/main/java/com/netscape/ca/CertificateAuthority.java index 0b7b154a07d..b5117bd53a6 100644 --- a/base/ca/src/main/java/com/netscape/ca/CertificateAuthority.java +++ b/base/ca/src/main/java/com/netscape/ca/CertificateAuthority.java @@ -913,6 +913,28 @@ public CertificateChain getCACertChain() { */ public X509CertImpl getCACert() throws EBaseException { + // For external-key authorities mSigningUnit is not initialised + // (initSigningUnits() returns early for the #external#: sentinel). + // Fall back to the certificate repository using the serial number + // stored in the authority LDAP record. + if (mSigningUnit == null) { + if (authoritySerial != null && certRepository != null) { + try { + X509CertImpl cert = certRepository.getX509Certificate(authoritySerial); + if (cert != null) { + return cert; + } + } catch (Exception e) { + logger.warn("CertificateAuthority: Failed to retrieve cert" + + " from repository for serial 0x{}: {}", + authoritySerial.toString(16), e.getMessage()); + } + } + throw new EBaseException( + "CA signing unit not initialised and no certificate available" + + " in repository for authority " + authorityID); + } + X509CertImpl caCertImpl = mSigningUnit.getCertImpl(); if (caCertImpl != null) { return caCertImpl;