# ACME Based Certificate Provisioning

ACME stands for Automatic Certificate Management Environment.

BastionXP can automate host, server, workload and device certificate provisioning using ACME protocol (RFC 8555).

BastionXP ACME server supports the following ACME challenge types: "http-01", "dns-01", "tls-alpn-01".

BastionXP can verify both domain name and IP address based host certificate requests using appropriate ACME challenge types. For example, DNS-01 type challenge is not available to verify IP address based certificate requests.

# Configuration

Enable ACME protocol based certificate provisioner in your BastionXP CA instance using the below sample configuration.

{
    "mode": "auth",
    "email": "[email protected]",
    "gateway_domain": "ca.internal.example.com",
    "provisioners": [
        {
            "type": "ACME",
            "name": "acme",
            "tos": "https://www.example.com/tos",
            "website": "https://www.example.com",
            "dns_server": "dns.internal.example.com:1053",
            "challenges": ["http-01", "dns-01", "tls-alpn-01"],
            "require_eab": true,
            "policy": {
                "x509": {
                    "dns": {
                        "allow": ["*.internal.example.com"],
                        "deny": ["abc.internal.example.com", "xyz.internal.example.com"],
                        "allow_wildcards": true,
                    },
                    "ip": {
                        "allow": ["10.1.1.0/24", "20.1.1.4", "30.2.29.125/32"],
                        "deny": ["2.2.0.0/16", "3.4.5.1"]
                    }
                }
            }
        }
    ]	
}

BastionXP CA's Policy Engine provides you with a fine-grained control over ACME certificate provisioning using user-defined custom policy configurations. BastionXP CA provides you the option of creating and applying custom policies to the ACME certificate provisioner. You can allow or deny certificates from being issued to certain domains or IP addresses.

BastionXP also implements ACME External Account Binding(EAB). BastionXP restricts who can get certificate from the ACME server by enforcing ACME EAB. ACME clients need to provide a valid EAB credentials to register an account with the server, thereby preventing anonymous ACME clients from registering and obtaining certificates from the CA.

# ACME Directory URL:

The ACME directory URL is: https://ca.internal.example.com/acme/directory

# How to get a Certificate from BastionXP using ACME Clients

# Using Certbot

To register an ACME account with the BastionXP CA using certbot, use the below command:

	$ sudo certbot register --agree-tos -m [email protected] \
 		--server https://ca.internal.example.com/acme/directory

To get a certificate from BastionXP CA using certbot you need to:

  • Provide certbot with your ACME directory URL using the --server flag
  • Make certbot to trust your CA's root certificate using the REQUESTS_CA_BUNDLE environment variable

For example:

	$ sudo REQUESTS_CA_BUNDLE=$(HOME_DIR)/certs/root_ca.crt \
		certbot certonly -n --standalone -d db-001.internal.example.com \
		--server https://ca.internal.example.com/acme/directory

Description:

  • sudo is required in certbot's standalone mode so that it can run a HTTP server on port 80 to complete the http-01 challenge.
  • If you already have a HTTP webserver running, you can use webroot mode instead.
  • With the appropriate plugin certbot also supports the dns-01 challenge for most popular DNS providers such as AWS, GCP, Cloudflare and more.

To renew all your certificates you've installed using cerbot, run the below command:

	$ sudo REQUESTS_CA_BUNDLE=$(HOME_DIR)/certs/root_ca.crt \
		certbot renew

# Using acme.sh

To get a certificate from BastionXP CA using acme.sh you need to:

  • Provide acme.sh with your ACME directory URL using the --server flag
  • Make acme.sh to trust your root certificate using the --ca-bundle flag

For example:

    $ sudo acme.sh --issue --standalone -d db-001.internal.example.com \
        --server https://ca.internal.example.com/acme/directory \
        --ca-bundle $(HOME_DIR)/certs/root_ca.crt \
        --fullchain-file db-001.crt \
        --key-file db-001.key

acme.sh can solve the http-01 challenge in standalone mode and webroot mode. It can also solve the dns-01 challenge for various DNS providers.

# Using Lego

To get a certificate using Lego you can use the below sample Go code to use the Lego library:

package main

import (
	"crypto"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"fmt"
	"log"

	"github.com/go-acme/lego/v4/certcrypto"
	"github.com/go-acme/lego/v4/certificate"
	"github.com/go-acme/lego/v4/challenge/http01"
	"github.com/go-acme/lego/v4/lego"
	"github.com/go-acme/lego/v4/registration"
)

// You'll need a user or account type that implements acme.User
type MyUser struct {
	Email        string
	Registration *registration.Resource
	key          crypto.PrivateKey
}

func (u *MyUser) GetEmail() string {
	return u.Email
}
func (u MyUser) GetRegistration() *registration.Resource {
	return u.Registration
}
func (u *MyUser) GetPrivateKey() crypto.PrivateKey {
	return u.key
}

func main() {
	// Create a user. New accounts need an email and private key to start.
	privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		log.Fatal(err)
	}

	myUser := MyUser{
		Email: "[email protected]",
		key:   privateKey,
	}

	config := lego.NewConfig(&myUser)

	config.CADirURL = "https://ca.internal.example.com/acme/directory"
	config.Certificate.KeyType = certcrypto.EC256

	// A client facilitates communication with the CA server.
	client, err := lego.NewClient(config)
	if err != nil {
		log.Fatal(err)
	}

	err = client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "80"))
	if err != nil {
		log.Fatal("HTTP-01 challenge error: ", err)
	}

	// New users will need to register
	reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
	if err != nil {
		log.Fatal(err)
	}

	myUser.Registration = reg

	request := certificate.ObtainRequest{
		Domains: []string{"db-001.internal.example.com"}, // Obtain cert for this domain
		Bundle:  true,
	}

	certificates, err := client.Certificate.Obtain(request)
	if err != nil {
		log.Fatal(err)
	}

	// Each certificate comes back with the cert bytes, the bytes of the client's
	// private key, and a certificate URL. SAVE THESE TO DISK.
	fmt.Printf("%#v\n", certificates)

	// ... all done.
}

# Using Go Autocert

You can use the Go's simple ACME client library (Autocert) to obtain a certificate for your Go server on-the-fly from BastionXP CA, by using the below sample code:


package main

import (
	"crypto/tls"
	"log"
	"net/http"
	"path/filepath"

	"github.com/gin-gonic/gin"
	"golang.org/x/crypto/acme"
	"golang.org/x/crypto/acme/autocert"
)

func main() {

	dirCachePath := filepath.Join("/tmp", "acme-cert")
	certManager := autocert.Manager{
		Prompt:     autocert.AcceptTOS,
		HostPolicy: autocert.HostWhitelist("db-001.internal.example.com"), //Your domain here
		Cache:  autocert.DirCache(dirCachePath), //Folder for storing certificates
		Client: &acme.Client{DirectoryURL: "https://ca.internal.example.com/acme/directory"},
	}

	tlsConfig := &tls.Config{
		GetCertificate: certManager.GetCertificate,
	}

	log.Println("ACME HTTP server listening on port: 80")
	go http.ListenAndServe(":80", certManager.HTTPHandler(nil))

	// Auth server listens on port 443
	server := http.Server{
		Addr:      ":443",
		Handler:   gin.Default(),
		TLSConfig: tlsConfig,
	}

	defer server.Close()

	log.Println("Server listening on port: ", "443")
	log.Fatal(server.ListenAndServeTLS("", "")) // force to use tls.Config
}

# Using Node.JS

You can use the NodeJS acme-client library to obtain a certificate for your NodeJS server on-the-fly from BastionXP CA, by using the below sample code:

const acme = require('acme-client');
const express = require('express');
const http = require('http');

const DIRECTORY_URL = 'https://ca.internal.example.com/acme/directory'
const DOMAIN = 'db-001.internal.example.com'
const EMAIL = '[email protected]'

/**
 * HTTP server for HTTP-01 challenge
 */
const app = express();

// Global store to hold active challenges
const activeChallenges = new Map();

/**
 * 1. The Express Route
 * This serves the keyAuthorization to the ACME CA
 */
app.get('/.well-known/acme-challenge/:token', (req, res) => {
    const token = req.params.token;
    const keyAuth = activeChallenges.get(token);

    console.log(`[ACME] Challenge requested. Token: ${token} | Found: ${!!keyAuth}`);

    if (!keyAuth) {
        return res.status(404).send('Challenge not found');
    }

    // ACME requires text/plain or no content-type at all
    res.set('Content-Type', 'text/plain');
    res.send(keyAuth);
});

const httpServer = http.createServer(app).listen(80, () => {
    console.log('Challenge server listening on port 80');
});

/**
 * 2. The Provisioning Functions
 */
async function myCustomChallengeProvisioner(token, keyAuthorization) {
    console.log(`[ACME] Storing challenge for token: ${token}`);
    activeChallenges.set(token, keyAuthorization);
}

async function myCustomChallengeRemover(token) {
    console.log(`[ACME] Removing challenge for token: ${token}`);
    activeChallenges.delete(token);
}

async function runAcmeClient() {
    try {
        /* 1. Initialize Client */
        const client = new acme.Client({
            directoryUrl: DIRECTORY_URL, // Private CA URL
            accountKey: await acme.crypto.createPrivateKey()
        });

        /* FIX: Register the account before doing anything else */
        await client.createAccount({
            termsOfServiceAgreed: true,
            contact: ['mailto:'+EMAIL]
        });    

        /* 2. Create Order */
        const order = await client.createOrder({
            identifiers: [{ type: 'dns', value: DOMAIN }]
        });

        /* 3. Handle Authorizations & Challenges */
        const authorizations = await client.getAuthorizations(order);
        for (const authz of authorizations) {
            const challenge = authz.challenges.find(c => c.type === 'http-01'); // or dns-01
            const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);

            // ACTION: Provision your challenge here (e.g., write file to web server)
            await myCustomChallengeProvisioner(challenge.token, keyAuthorization);

            /* Notify CA that challenge is ready */
            await client.completeChallenge(challenge);
            
            /* Wait for CA to validate this specific authorization */
            await client.waitForValidStatus(authz);

            await myCustomChallengeRemover(challenge.token);
        }

        /* 4. Finalize Order (Transitions from 'ready' to 'processing'/'valid') */
        const [key, csr] = await acme.crypto.createCsr({
            altNames: [DOMAIN],
        });

        // Submitting the CSR to the CA
        await client.finalizeOrder(order, csr);

        /* 5. Wait for Certificate Issuance */
        // Private CAs may take time to sign; poll until status is 'valid'
        const finalizedOrder = await client.waitForValidStatus(order);

        /* 6. Download Certificate */
        const certificate = await client.getCertificate(finalizedOrder);
        
        console.log('Certificate issued:\n', certificate);
        return { key, certificate };

    } catch (err) {
        console.error('Error:', err);
    } finally {
        console.log('Closing HTTP server...');
        // This allows the process to exit once the server stops listening
        httpServer.close(); 
    }
}

runAcmeClient().then(() => {
    console.log('ACME client finished.');
});