Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
308 changes: 308 additions & 0 deletions .github/workflows/ca-authority-external-key-test.yml
Original file line number Diff line number Diff line change
@@ -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/<id> 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
"
Comment on lines +155 to +162

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
bash -c 'false | tee /dev/null' >/dev/null 2>&1
status_plain=$?

bash -o pipefail -c 'false | tee /dev/null' >/dev/null 2>&1
status_pipefail=$?

printf 'plain bash -c status: %s\n' "$status_plain"
printf 'bash -o pipefail -c status: %s\n' "$status_pipefail"
# Expected: plain=0, pipefail!=0

Repository: dogtagpki/pki

Length of output: 114


🏁 Script executed:

head -n 130 .github/workflows/ca-authority-external-key-test.yml | tail -n 20

Repository: dogtagpki/pki

Length of output: 895


🏁 Script executed:

grep -n "authority-create-output" .github/workflows/ca-authority-external-key-test.yml

Repository: dogtagpki/pki

Length of output: 182


🏁 Script executed:

sed -n '130,150p' .github/workflows/ca-authority-external-key-test.yml

Repository: dogtagpki/pki

Length of output: 971


Add -o pipefail to preserve ca-authority-create failures through the tee pipeline.

The inner bash -c does not enable pipefail, so this step returns tee's status instead of pki's. If pki ca-authority-create fails, the command exit status will be 0, and the workflow continues until the output validation fails one step later.

Suggested fix
-          docker exec pki bash -c "
+          docker exec pki bash -o pipefail -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
           "
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
"
docker exec pki bash -o pipefail -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
"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/ca-authority-external-key-test.yml around lines 123 - 130,
The pipeline running inside docker exec uses bash -c wrapping the pki -n caadmin
ca-authority-create command piped to tee, but pipefail is not enabled so tee's
exit status hides failures from pki; update the inner shell invocation (the bash
-c that runs pki -n caadmin ca-authority-create | tee ...) to enable pipefail
(e.g., prepend a shell setting like set -o pipefail or use bash -o pipefail) so
that the exit status of pki is preserved and non-zero failures propagate through
the pipeline.


- 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
Comment on lines +285 to +288

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make the post-delete assertion fail when the authority still exists.

docker exec ... && exit 1 || true always returns success, so this step no longer proves deletion. If ca-authority-show still succeeds, the workflow still passes.

Proposed fix
       - 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
+          if docker exec pki pki ca-authority-show "${AID}"; then
+              echo "Authority ${AID} still exists after deletion" >&2
+              exit 1
+          fi
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/ca-authority-external-key-test.yml around lines 250 - 253,
The workflow step that verifies deletion uses `docker exec pki pki
ca-authority-show ${AID} && exit 1 || true`, which always exits success; replace
it with a conditional that fails the step when `pki ca-authority-show` succeeds:
run `docker exec pki cat /tmp/authority-id` to set AID as before, then run
`docker exec pki pki ca-authority-show ${AID}` and if that command returns
success, explicitly exit 1 (e.g., if ...; then echo "authority still exists";
exit 1; fi) so the job fails when the authority still exists. Ensure the step
does not unconditionally swallow failures.


- 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 {} \;
5 changes: 5 additions & 0 deletions .github/workflows/subca-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion base/ca/shared/conf/CS.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading