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:
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.