diff --git a/.github/workflows/acme-algorithm-test.yml b/.github/workflows/acme-algorithm-test.yml new file mode 100644 index 00000000000..b4f7413b2df --- /dev/null +++ b/.github/workflows/acme-algorithm-test.yml @@ -0,0 +1,308 @@ +name: ACME with certbot + +on: workflow_call + +env: + DB_IMAGE: ${{ vars.DB_IMAGE || 'quay.io/389ds/dirsrv' }} + +jobs: + # docs/installation/acme/Installing_PKI_ACME_Responder.md + # docs/user/acme/Using_PKI_ACME_Responder_with_Certbot.md + test: + name: Test + runs-on: ubuntu-latest + env: + SHARED: /tmp/workdir/pki + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Retrieve ACME images + uses: actions/cache@v4 + with: + key: acme-images-${{ github.sha }} + path: acme-images.tar + + - name: Load ACME images + run: docker load --input acme-images.tar + + - name: Create network + run: docker network create example + + - name: Set up DS container + run: | + tests/bin/ds-container-create.sh ds + env: + IMAGE: ${{ env.DB_IMAGE }} + HOSTNAME: ds.example.com + PASSWORD: Secret.123 + + - name: Connect DS container to network + run: docker network connect example ds --alias ds.example.com + + - name: Set up PKI container + run: | + tests/bin/runner-init.sh pki + env: + HOSTNAME: pki.example.com + + - name: Connect PKI container to network + run: docker network connect example pki --alias pki.example.com + + - name: Install CA in PKI container + 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 admin cert + run: | + docker exec pki pki-server cert-export ca_signing --cert-file ca_signing.crt + docker exec pki pki client-cert-import ca_signing --ca-cert ca_signing.crt + docker exec pki pki pkcs12-import \ + --pkcs12 /root/.dogtag/pki-tomcat/ca_admin_cert.p12 \ + --pkcs12-password Secret.123 + docker exec pki pki -n caadmin ca-user-show caadmin + + - name: Set up ACME database in DS container + run: | + docker exec ds ldapmodify \ + -H ldap://ds.example.com:3389 \ + -D "cn=Directory Manager" \ + -w Secret.123 \ + -f $SHARED/base/acme/database/ds/schema.ldif + docker exec ds ldapadd \ + -H ldap://ds.example.com:3389 \ + -D "cn=Directory Manager" \ + -w Secret.123 \ + -f $SHARED/base/acme/database/ds/create.ldif + docker exec ds ldapadd \ + -H ldap://ds.example.com:3389 \ + -D "cn=Directory Manager" \ + -w Secret.123 \ + -f $SHARED/base/acme/realm/ds/create.ldif + + - name: Install ACME in PKI container + run: | + docker exec pki pki-server acme-create + docker exec pki pki-server acme-database-mod \ + --type ds \ + -D url=ldap://ds.example.com:3389 + docker exec pki pki-server acme-issuer-mod --type pki + docker exec pki pki-server acme-realm-mod \ + --type ds \ + -D url=ldap://ds.example.com:3389 + docker exec pki pki-server acme-deploy --wait + + - name: Run PKI healthcheck in PKI container + run: docker exec pki pki-healthcheck --failures-only + + - name: Verify ACME in PKI container + run: docker exec pki pki acme-info + + - name: Set up client container + run: | + tests/bin/runner-init.sh client + env: + HOSTNAME: client.example.com + + - name: Connect client container to network + run: docker network connect example client --alias client.example.com + + - name: Install certbot in client container + run: docker exec client dnf install -y certbot + + - name: Register ACME account + run: | + docker exec client certbot register \ + --server http://pki.example.com:8080/acme/directory \ + --email testuser@example.com \ + --agree-tos \ + --non-interactive + + - name: Enroll using RS256 CSR + run: | + docker exec client certbot certonly \ + --server http://pki.example.com:8080/acme/directory \ + --key-type rsa \ + --rsa-key-size 2048 \ + --domain client.example.com \ + --standalone \ + --non-interactive \ + --verbose + + - name: Check RS256 client locally + run: | + docker exec client pki client-cert-import \ + --cert /etc/letsencrypt/live/client.example.com/fullchain.pem \ + rs256 + + # store serial number + docker exec client pki nss-cert-show rs256 | tee output + sed -n 's/^ *Serial Number: *\(.*\)/\1/p' output > rs256.txt + + # subject should be CN=client.example.com + echo "CN=client.example.com" > expected + sed -n 's/^ *Subject DN: *\(.*\)/\1/p' output > actual + diff expected actual + + - name: Check ACME orders after RS256 enrollment + run: | + docker exec ds ldapsearch \ + -H ldap://ds.example.com:3389 \ + -D "cn=Directory Manager" \ + -w Secret.123 \ + -b ou=orders,dc=acme,dc=pki,dc=example,dc=com \ + -s one \ + -o ldif_wrap=no \ + -LLL | tee output + + # there should be one order + echo "1" > expected + grep "^dn:" output | wc -l > actual + diff expected actual + + - name: Check ACME authorizations after RS256 enrollment + run: | + docker exec ds ldapsearch \ + -H ldap://ds.example.com:3389 \ + -D "cn=Directory Manager" \ + -w Secret.123 \ + -b ou=authorizations,dc=acme,dc=pki,dc=example,dc=com \ + -s one \ + -o ldif_wrap=no \ + -LLL | tee output + + # there should be one authorization + echo "1" > expected + grep "^dn:" output | wc -l > actual + diff expected actual + + - name: Check ACME challenges after RS256 enrollment + run: | + docker exec ds ldapsearch \ + -H ldap://ds.example.com:3389 \ + -D "cn=Directory Manager" \ + -w Secret.123 \ + -b ou=challenges,dc=acme,dc=pki,dc=example,dc=com \ + -s one \ + -o ldif_wrap=no \ + -LLL | tee output + + # there should be one challenge + echo "1" > expected + grep "^dn:" output | wc -l > actual + diff expected actual + + - name: Check CA certs after RS256 enrollments + run: | + docker exec pki pki ca-cert-find | tee output + + # there should be 7 certs + echo "7" > expected + grep "Serial Number:" output | wc -l > actual + diff expected actual + + # check client cert + SERIAL=$(cat rs256.txt) + docker exec pki pki ca-cert-show $SERIAL | tee output + + # subject should be CN=client.example.com + echo "CN=client.example.com" > expected + sed -n 's/^ *Subject DN: *\(.*\)/\1/p' output > actual + diff expected actual + + - name: Enroll using ES256 CSR + run: | + docker exec client certbot certonly \ + --server http://pki.example.com:8080/acme/directory \ + --domain client.example.com \ + --key-type ecdsa \ + --elliptic-curve secp256r1 \ + --standalone \ + --non-interactive \ + --duplicate \ + --verbose + + - name: Check ES256 client locally + run: | + docker exec client pki client-cert-import \ + --cert /etc/letsencrypt/live/client.example.com/fullchain.pem \ + es256 + + # store serial number + docker exec client pki nss-cert-show es256 | tee output + sed -n 's/^ *Serial Number: *\(.*\)/\1/p' output > es256.txt + + # subject should be CN=client.example.com + echo "CN=client.example.com" > expected + sed -n 's/^ *Subject DN: *\(.*\)/\1/p' output > actual + diff expected actual + + - name: Check ACME orders after ES256 enrollment + run: | + docker exec ds ldapsearch \ + -H ldap://ds.example.com:3389 \ + -D "cn=Directory Manager" \ + -w Secret.123 \ + -b ou=orders,dc=acme,dc=pki,dc=example,dc=com \ + -s one \ + -o ldif_wrap=no \ + -LLL | tee output + + # there should be two orders + echo "2" > expected + grep "^dn:" output | wc -l > actual + diff expected actual + + - name: Check ACME authorizations after ES256 enrollment + run: | + docker exec ds ldapsearch \ + -H ldap://ds.example.com:3389 \ + -D "cn=Directory Manager" \ + -w Secret.123 \ + -b ou=authorizations,dc=acme,dc=pki,dc=example,dc=com \ + -s one \ + -o ldif_wrap=no \ + -LLL | tee output + + # there should be two authorizations + echo "2" > expected + grep "^dn:" output | wc -l > actual + diff expected actual + + - name: Check ACME challenges after ES256 enrollment + run: | + docker exec ds ldapsearch \ + -H ldap://ds.example.com:3389 \ + -D "cn=Directory Manager" \ + -w Secret.123 \ + -b ou=challenges,dc=acme,dc=pki,dc=example,dc=com \ + -s one \ + -o ldif_wrap=no \ + -LLL | tee output + + # there should be two challenges + echo "2" > expected + grep "^dn:" output | wc -l > actual + diff expected actual + + - name: Check CA certs after enrollments + run: | + docker exec pki pki ca-cert-find | tee output + + # there should be 8 certs + echo "8" > expected + grep "Serial Number:" output | wc -l > actual + diff expected actual + + # check client cert + SERIAL=$(cat es256.txt) + docker exec pki pki ca-cert-show $SERIAL | tee output + + # subject should be CN=client.example.com + echo "CN=client.example.com" > expected + sed -n 's/^ *Subject DN: *\(.*\)/\1/p' output > actual + diff expected actual diff --git a/.github/workflows/acme-tests.yml b/.github/workflows/acme-tests.yml index 824576cee0c..632c946f86e 100644 --- a/.github/workflows/acme-tests.yml +++ b/.github/workflows/acme-tests.yml @@ -112,6 +112,11 @@ jobs: needs: build uses: ./.github/workflows/acme-postgresql-test.yml + acme-algorithm-test: + name: ACME CSRs with different signatures + needs: build + uses: ./.github/workflows/acme-algorithm-test.yml + publish: if: github.event_name == 'push' && github.ref_name == 'master' name: Publishing ACME images diff --git a/CMakeLists.txt b/CMakeLists.txt index ad057af0b6c..eb63cda66bb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ # Required cmake version -cmake_minimum_required(VERSION 3.0.0) +cmake_minimum_required(VERSION 3.5) project(pki) @@ -35,9 +35,9 @@ if (NOT DEFINED THEME) set(THEME "dogtag") endif(NOT DEFINED THEME) -string(REGEX REPLACE "^([0-9]+).*" "\\1" APPLICATION_VERSION_MAJOR ${VERSION}) -string(REGEX REPLACE "^[0-9]+\\.([0-9]+).*" "\\1" APPLICATION_VERSION_MINOR ${VERSION}) -string(REGEX REPLACE "^[0-9]+\\.[0-9]+\\.([0-9]+).*" "\\1" APPLICATION_VERSION_PATCH ${VERSION}) +string(REGEX REPLACE "^([0-9]+).*" "\\1" "APPLICATION_VERSION_MAJOR" "${VERSION}") +string(REGEX REPLACE "^[0-9]+\\.([0-9]+).*" "\\1" "APPLICATION_VERSION_MINOR" "${VERSION}") +string(REGEX REPLACE "^[0-9]+\\.[0-9]+\\.([0-9]+).*" "\\1" "APPLICATION_VERSION_PATCH" "${VERSION}") set(APP_SERVER "tomcat-9.0" CACHE STRING "Application server") diff --git a/base/acme/src/main/java/org/dogtagpki/acme/server/ACMEEngine.java b/base/acme/src/main/java/org/dogtagpki/acme/server/ACMEEngine.java index 92a8baa99a5..700de81a377 100644 --- a/base/acme/src/main/java/org/dogtagpki/acme/server/ACMEEngine.java +++ b/base/acme/src/main/java/org/dogtagpki/acme/server/ACMEEngine.java @@ -37,6 +37,7 @@ import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.StringUtils; import org.dogtagpki.acme.ACMEAccount; +import org.dogtagpki.acme.ACMEAlgorithm; import org.dogtagpki.acme.ACMEAuthorization; import org.dogtagpki.acme.ACMEError; import org.dogtagpki.acme.ACMEIdentifier; @@ -375,39 +376,37 @@ public void initMonitors(String filename) throws Exception { } // the default class just sends the default config values - Class configSourceClass - = ACMEEngineConfigDefaultSource.class; + Class configSourceClass = ACMEEngineConfigDefaultSource.class; String className = monitorsConfig.getProperty("engine.class"); if (className != null && !className.isEmpty()) { - configSourceClass = - (Class) Class.forName(className); + configSourceClass = (Class) Class.forName(className); } engineConfigSource = configSourceClass.getDeclaredConstructor().newInstance(); // We pass to the ConfigSource only the callbacks needed to set - // the configuration (Consumer). This abstraction ensures the + // the configuration (Consumer). This abstraction ensures the // ConfigSource has no direct access to the ACMEEngine instance. engineConfigSource.setEnabledConsumer(new Consumer() { - @Override public void accept(Boolean b) { + @Override + public void accept(Boolean b) { config.setEnabled(b); logger.info( - "ACME service is " - + (b ? "enabled" : "DISABLED") - + " by configuration" - ); + "ACME service is " + + (b ? "enabled" : "DISABLED") + + " by configuration"); } }); engineConfigSource.setWildcardConsumer(new Consumer() { - @Override public void accept(Boolean b) { + @Override + public void accept(Boolean b) { config.getPolicyConfig().setEnableWildcards(b); logger.info( - "ACME wildcard issuance is " - + (b ? "enabled" : "DISABLED") - + " by configuration" - ); + "ACME wildcard issuance is " + + (b ? "enabled" : "DISABLED") + + " by configuration"); } }); @@ -455,7 +454,7 @@ public void start() throws Exception { loadConfig(acmeConfDir + File.separator + "engine.conf"); Boolean noncePersistent = config.getNoncesPersistent(); - this.noncesPersistent = noncePersistent != null ? noncePersistent : false; + this.noncesPersistent = noncePersistent != null ? noncePersistent : false; initRandomGenerator(); initMetadata(acmeConfDir + File.separator + "metadata.conf"); @@ -470,7 +469,8 @@ public void start() throws Exception { } public void shutdownDatabase() throws Exception { - if (database == null) return; + if (database == null) + return; database.close(); database = null; @@ -486,28 +486,32 @@ public void shutdownValidators() throws Exception { } public void shutdownIssuer() throws Exception { - if (issuer == null) return; + if (issuer == null) + return; issuer.close(); issuer = null; } public void shutdownScheduler() throws Exception { - if (scheduler == null) return; + if (scheduler == null) + return; scheduler.shutdown(); scheduler = null; } public void shutdownMonitors() throws Exception { - if (engineConfigSource == null) return; + if (engineConfigSource == null) + return; engineConfigSource.shutdown(); engineConfigSource = null; } public void shutdownRealm() throws Exception { - if (realm == null) return; + if (realm == null) + return; realm.stop(); realm = null; @@ -604,40 +608,38 @@ public void removeExpiredRecords(Date currentTime) throws Exception { public void validateJWS(JWS jws, String alg, JWK jwk) throws Exception { - // TODO: support other algorithms - Signature signer; PublicKey publicKey; + ACMEAlgorithm acmeAlgo; - if ("RS256".equals(alg)) { - - signer = Signature.getInstance("SHA256withRSA", "Mozilla-JSS"); - - String kty = jwk.getKty(); - KeyFactory keyFactory = KeyFactory.getInstance(kty, "Mozilla-JSS"); - - String n = jwk.getN(); - BigInteger modulus = new BigInteger(1, Base64.decodeBase64(n)); - - String e = jwk.getE(); - BigInteger publicExponent = new BigInteger(1, Base64.decodeBase64(e)); - - RSAPublicKeySpec keySpec = new RSAPublicKeySpec(modulus, publicExponent); - publicKey = keyFactory.generatePublic(keySpec); - - } else { + try { + acmeAlgo = ACMEAlgorithm.fromString(alg); + } catch(Exception e) { ResponseBuilder builder = Response.status(Response.Status.BAD_REQUEST); builder.type("application/problem+json"); ACMEError error = new ACMEError(); error.setType("urn:ietf:params:acme:error:badSignatureAlgorithm"); - error.setDetail("Signature of type " + alg + " not supported\n" + - "Try again with RS256."); + error.setDetail("Signature of type " + alg + " not supported"); builder.entity(error); throw new WebApplicationException(builder.build()); } + signer = Signature.getInstance(acmeAlgo.getJCA(), "Mozilla-JSS"); + + String kty = jwk.getKty(); + KeyFactory keyFactory = KeyFactory.getInstance(kty, "Mozilla-JSS"); + + String n = jwk.getN(); + BigInteger modulus = new BigInteger(1, Base64.decodeBase64(n)); + + String e = jwk.getE(); + BigInteger publicExponent = new BigInteger(1, Base64.decodeBase64(e)); + + RSAPublicKeySpec keySpec = new RSAPublicKeySpec(modulus, publicExponent); + publicKey = keyFactory.generatePublic(keySpec); + validateJWS(jws, signer, publicKey); } @@ -791,7 +793,9 @@ public void addOrder(ACMEAccount account, ACMEOrder order) throws Exception { database.addOrder(order); } - enum CheckOrderResult { ORDER_ACCOUNT_MISMATCH , ORDER_EXPIRED , ORDER_ACCESS_OK, ORDER_NULL} + enum CheckOrderResult { + ORDER_ACCOUNT_MISMATCH, ORDER_EXPIRED, ORDER_ACCESS_OK, ORDER_NULL + } public CheckOrderResult checkOrder(ACMEAccount account, ACMEOrder order) { @@ -900,8 +904,8 @@ public void validateCSR(ACMEAccount account, ACMEOrder order, PKCS10 pkcs10) thr if (!unauthorizedDNSNames.isEmpty()) { // TODO: generate proper exception throw new Exception( - "Unauthorized DNS names: " - + StringUtils.join(unauthorizedDNSNames, ", ")); + "Unauthorized DNS names: " + + StringUtils.join(unauthorizedDNSNames, ", ")); } // check for authorized names missing from CSR @@ -910,8 +914,8 @@ public void validateCSR(ACMEAccount account, ACMEOrder order, PKCS10 pkcs10) thr if (!extraAuthorizedDNSNames.isEmpty()) { // TODO: generate proper exception throw new Exception( - "Missing DNS names from order: " - + StringUtils.join(extraAuthorizedDNSNames, ", ")); + "Missing DNS names from order: " + + StringUtils.join(extraAuthorizedDNSNames, ", ")); } // TODO: validate other things in CSR @@ -925,9 +929,9 @@ public void validateRevocation(ACMEAccount account, ACMERevocation revocation) t // // The server MUST consider at least the following accounts authorized // for a given certificate: - // - the account that issued the certificate. - // - an account that holds authorizations for all of the identifiers in - // the certificate. + // - the account that issued the certificate. + // - an account that holds authorizations for all of the identifiers in + // the certificate. Date now = new Date(); String certBase64 = revocation.getCertificate(); @@ -961,11 +965,13 @@ public void validateRevocation(ACMEAccount account, ACMERevocation revocation) t Collection identifiers = getCertIdentifiers(cert); if (identifiers.isEmpty()) { - /* Protect against vacuous authorisation. If there are no + /* + * Protect against vacuous authorisation. If there are no * identifiers, it could be e.g. a user or CA certificate. * Without this check that there are at least /some/ identifiers * to authorise, every account would be vacuously authorised - * to revoke it. */ + * to revoke it. + */ throw new Exception("Certificate has no ACME identifiers."); } @@ -1010,7 +1016,7 @@ public void validateRevocation(ACMEAccount account, ACMERevocation revocation) t logger.info("Account has no authorizations for:"); for (ACMEIdentifier identifier : identifiers) { logger.info("- " + identifier.getType() + ": " + identifier.getValue()); - } + } // TODO: generate proper exception throw new Exception("Account has no authorizations for " + identifiers); diff --git a/base/common/src/main/java/org/dogtagpki/acme/ACMEAlgorithm.java b/base/common/src/main/java/org/dogtagpki/acme/ACMEAlgorithm.java new file mode 100644 index 00000000000..b0304701f0e --- /dev/null +++ b/base/common/src/main/java/org/dogtagpki/acme/ACMEAlgorithm.java @@ -0,0 +1,38 @@ +package org.dogtagpki.acme; + +/** + * @author Minsu Park + */ + +public enum ACMEAlgorithm { + // RFC 7518 Appendix A.1 + // Digital Signature/MAC Algorithm Identifier Cross-Reference + + // TODO: Implement HS256, HS384, HS512 once JSS provider + // implements those algorithms + RS256("RS256", "SHA256withRSA"), + RS384("RS384", "SHA384withRSA"), + RS512("RS512", "SHA512withRSA"), + ES256("ES256", "SHA256withECDSA"), + ES384("ES384", "SHA384withECDSA"), + ES512("ES512", "SHA512withECDSA"), + PS256("PS256", "SHA256withRSA/PSS"), + PS384("PS384", "SHA384withRSA/PSS"), + PS512("PS512", "SHA512withRSA/PSS"); + + private String alg; + private String jca; + + private ACMEAlgorithm(String alg, String jca) { + this.alg = alg; + this.jca = jca; + } + + public String getJCA() { + return jca; + } + + public static ACMEAlgorithm fromString(String alg) throws IllegalArgumentException { + return ACMEAlgorithm.valueOf(alg.toUpperCase()); + } +}