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..6be90dfd667 --- /dev/null +++ b/.github/workflows/ca-authority-external-key-test.yml @@ -0,0 +1,308 @@ +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: +# - 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 and pathLen:0 in Basic Constraints +# - The authority can be disabled and then 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. + # 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 "/O=EXAMPLE/CN=Test External Sub-CA" \ + -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: Verify caExternalKeyCACert profile is loaded + run: | + # 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 pki CLI + run: | + 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 output and extract ID + run: | + docker exec pki python3 -c " + import re, sys + text = open('/tmp/authority-create-output.txt').read() + print(text) + errors = [] + 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) + print('OK: External key=true, Ready to sign=false') + " + + - 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: | + # 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 + 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, 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, 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, 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) + echo Y | docker exec -i 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 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 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..b5117bd53a6 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(); @@ -901,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; 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 ec92021da88..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,17 +1269,46 @@ 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, profileId); + } - logger.info("CAEngine: signing certificate"); + /** + * 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: + *
    + *
  • {@link #generateSigningCert} — key pair generated locally, CSR + * produced internally, profile {@code caCACert}.
  • + *
  • {@link #createAuthorityRecord} with external CSR — key pair held + * by the caller (e.g. in an HSM), CSR supplied by the caller.
  • + *
+ * + * @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 sub-CA CSR with profile {}", profileId); ProfileSubsystem ps = getProfileSubsystem(); - String profileId = "caCACert"; Profile profile = ps.getProfile(profileId); + if (profile == null) { + throw new BadRequestDataException("Unknown profile: " + profileId); + } ArgBlock argBlock = new ArgBlock(); argBlock.set("cert_request_type", "pkcs10"); @@ -1295,7 +1324,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 +1332,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 +1362,34 @@ public AuthorityRecord createAuthorityRecord( String subjectDN, String description) throws Exception { + return createAuthorityRecord(parentAID, authToken, subjectDN, description, null, 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, + String profileId) + throws Exception { CertificateAuthority parentCA = getCA(parentAID); @@ -1365,47 +1421,98 @@ public AuthorityRecord createAuthorityRecord( record.setDescription(description); record.setEnabled(true); - CertificateAuthority hostCA = getCA(); + X509CertImpl cert = null; - String keyNickname = hostCA.getNickname() + " " + authorityID; - record.setKeyNickname(keyNickname); + 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. - record.addKeyHost(mConfig.getHostname() + ":" + getEESSLPort()); + authorityRepository.addAuthorityRecord(record); - authorityRepository.addAuthorityRecord(record); + try { + 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( + "CSR subject DN '" + csrSubject + + "' does not match requested DN '" + subjectX500Name + "'"); + } - X509CertImpl cert = null; + // 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"; - try { - int i = keyNickname.indexOf(':'); - String tokenname; - String nickname; + 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. - 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(); + + String keyNickname = hostCA.getNickname() + " " + authorityID; + record.setKeyNickname(keyNickname); + record.addKeyHost(mConfig.getHostname() + ":" + getEESSLPort()); + + authorityRepository.addAuthorityRecord(record); + + 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; + } - logger.info("CAEngine: Generating signing certificate"); - cert = generateSigningCert(parentCA, subjectX500Name, authToken, token); + CryptoToken token = CryptoUtil.getKeyStorageToken(tokenname); - logger.info("CAEngine: Importing " + nickname + " cert into " + token.getName()); - CryptoStore store = token.getCryptoStore(); - store.importCert(cert.getEncoded(), nickname); + // 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"; - } catch (Exception e) { - logger.error("Unable to generate signing certificate: " + e.getMessage(), e); + logger.info("CAEngine: Generating signing certificate with profile '{}'", + effectiveProfileId); + cert = generateSigningCert(parentCA, subjectX500Name, authToken, token, + effectiveProfileId); - // 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()); @@ -1689,7 +1796,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(); @@ -1708,6 +1819,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..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(); @@ -615,7 +638,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 +802,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 +813,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 +866,7 @@ private AuthorityData readAuthorityData(AuthorityRecord record) { } } - return new AuthorityData( + AuthorityData data = new AuthorityData( isHostAuthority, authorityDN.toString(), authorityID.toString(), @@ -845,6 +877,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/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"); + } +} 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) 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); } 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); 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; } } diff --git a/docs/changes/v11.10.0/API-Changes.adoc b/docs/changes/v11.10.0/API-Changes.adoc index 162c4e5a221..2fceb4f9cc1 100644 --- a/docs/changes/v11.10.0/API-Changes.adoc +++ b/docs/changes/v11.10.0/API-Changes.adoc @@ -1,5 +1,35 @@ = 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] and +link:../../user/tools/Creating-Sub-CA.adoc[Creating a Lightweight Sub-CA] +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..dd216c2f030 --- /dev/null +++ b/docs/user/tools/Creating-Sub-CA-with-External-Key.adoc @@ -0,0 +1,185 @@ += 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`. 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 == + +[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: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]