Skip to content

Offline courier

For hosts with no network path at all. Certificate issuance becomes one round trip over removable media: the airgapped inside host exports a signed request bundle; a connected outside companion runs the ACME orders and returns a signed response bundle; the inside host imports it.

[inside: airgapped]        removable media         [outside: connected]
 export  ──request.bundle──────────────────────►   run
 (keygen local,                                     (ACME + DNS creds,
  keys never leave)         ◄──response.bundle───    default-deny allowlist)
 import ◄───────────────────────────────────────

Both directions are Ed25519-signed and each side pins the other's public key, so a tampered or forged bundle fails closed. Only CSRs cross the airgap — the inside host's private keys never leave it.

One-time pairing

On each host, create an identity and print the public key:

$ gapcert courier init
courier identity created.
public key (give to the peer):
  cd543669e06348952b8c86247144819f8b8dfd82ad1a5a00f68a28545e192990
fingerprint:
  cedd6c4ab74b96c75e8ef4afa3680d377179c707543934710bb219722b027876

Exchange the two public keys out of band (read the fingerprint aloud, etc.), then pin the peer on each side:

$ gapcert courier pair -peer <peer-public-key-hex>
paired. verify this peer fingerprint out of band:
  cedd6c4a…027876

Each issuance

Inside — name what you want (no solver or CA settings; the outside decides those) and export:

# inside.yaml
data_dir: /var/lib/gapcert
certificates:
  - name: hmi
    domains: ["hmi.plant.example.com"]
    hook: "systemctl reload nginx"
$ gapcert courier export -config inside.yaml -out request.bundle
wrote request bundle request.bundle (request 86af87d4…d06d8a, 1 certificate(s))
carry it to the connected companion and run `gapcert courier run`.

The private key is generated and stashed locally; the bundle carries only CSRs.

Outside — the companion holds the ACME account and DNS credentials and a default-deny allowlist of what it will service:

# outside.yaml
data_dir: /var/lib/gapcert-courier
ca: letsencrypt
email: ops@example.com
allow:
  - match: "*.plant.example.com"
    solver: challenge-zone
solvers:
  challenge-zone:
    type: acme-dns
    api_base: https://acme-dns.example.com
    storage_path: /var/lib/gapcert-courier/acme-dns.json
$ gapcert courier run -config outside.yaml -in request.bundle -out response.bundle
wrote response bundle response.bundle. carry it back and run `gapcert courier import`.

Inside — carry the response back and import. Each returned certificate is verified to pair with the inside-held key and cover exactly the requested domains before install:

$ gapcert courier import -config inside.yaml -in response.bundle
hmi                  installed

Replay & tamper defense

  • Each request carries a random single-use id; the response must match a still-pending one, consumed on import.
  • Bundles carry a timestamp and expire after 14 days.
  • Signatures are domain-separated by bundle kind, so a request signature can never be reinterpreted as a response.
  • A malicious outside companion can only fail a request — it can't substitute a wrong-key or wrong-domain certificate, because import re-checks both against the inside's own configuration.

This path crosses an airgap trust boundary and went through its own security cadence. The honest residuals — especially clock dependence on airgapped hosts — are in the security model.