← The specification

Contract: the certificate a program serves beyond its machine

Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page

Status: Accepted

How a program lemonfiber builds comes by the certificate it serves to clients on other machines, keeps it current, and keeps its keys. One page, so that every such program, the MCP server’s HTTP mode first and the core’s remote access after it, offers the same four ways, reads the same settings, keeps the same state and fails the same way, while each implements it in its own language.

Satisfies: F13-R8, F13-R12, F13-R13


Why this is written down

Who can check a certificate decides which certificate is any use. A client lemonfiber wrote holds the pin of the certificate the stack made itself (ADR-0018, ADR-0025). A desktop client can be told to trust a root the operator installed. An assistant on a phone or in a browser can be told nothing: its provider’s servers make the connection, and they accept a chain to a publicly trusted authority and nothing else.

So there is no one right way, and there must always be a way that depends on no company outside the operator’s house. Four are offered, each said plainly to be checkable by whom. Getting a publicly trusted certificate is a protocol, ACME (RFC 8555), and keeping any certificate is a schedule; two programs that each invented their settings, their state and their renewal would be two things to learn and two ways to fail. So all of it is written here once. The code is not shared: the core is Rust and the MCP server Python, and a library crossing the two would be a third program to keep. What is shared is everything an operator, a volume or a test can see.

The four ways

LEMONFIBER_TLS_MODE chooses one. Setting both of files’ paths chooses files, and where nothing is chosen pinned is served: the one way that trusts nothing beyond a single certificate and depends on nobody. acme and private-ca are served only where LEMONFIBER_TLS_MODE names them, because each extends what has to be trusted: an authority’s word, or a root the operator installs.

Mode The certificate Who can check it
files The operator’s own: LEMONFIBER_TLS_CERTIFICATE and LEMONFIBER_TLS_PRIVATE_KEY Whoever trusts the authority that issued it
acme Obtained and renewed from any RFC 8555 authority: a public one (Let’s Encrypt by default, ZeroSSL, Google Trust Services, Buypass) or the operator’s own (step-ca, any private ACME directory; Pebble in tests) A public authority: every client, the assistants reached from a provider’s servers included. The operator’s own: whoever trusts its root
private-ca Issued by a root lemonfiber makes for this program and constrains to its names Whoever installed that root: the operator’s own devices and desktop clients. Not an assistant reached from a provider’s servers
pinned Made by the program itself, its SHA-256 fingerprint printed to pin A client given the fingerprint, as the companion is given the stack’s. Not an assistant reached from a provider’s servers

private-ca and pinned depend on nobody, and acme against the operator’s own authority depends on nobody but the operator. At start the program writes to its log which mode it serves and who can check it, in those words; where the mode is private-ca or pinned it also says that an assistant on a phone or in a browser reached through its provider cannot connect.

In every mode nothing is answered in plain HTTP but the token of a pending HTTP-01 challenge.

The names a certificate is for are LEMONFIBER_NAMES: one or more host names, or for private-ca and pinned also addresses, separated by commas.

files

Both files are read at start and refused, by name, where either is unreadable, the key does not match the certificate, the certificate has expired, or it does not cover every name in LEMONFIBER_NAMES where that is set. They are read again, with no restart, when either changes, so a certificate renewed by something else is taken. This is also how two programs on one machine share one certificate: a program beside a core that already holds one for the name reads the core’s files and opens no second account.

acme

Setting Default Holds
LEMONFIBER_ACME_DIRECTORY https://acme-v02.api.letsencrypt.org/directory Any RFC 8555 directory
LEMONFIBER_ACME_ROOTS the system’s A PEM file of the roots the directory’s own TLS is checked against, for an authority the operator runs
LEMONFIBER_ACME_CONTACT none A mailto: address the authority may write to
LEMONFIBER_ACME_EAB_KID, LEMONFIBER_ACME_EAB_HMAC_FILE none External Account Binding (RFC 8555 §7.3.4): the key identifier, and a file holding the HMAC key
LEMONFIBER_ACME_CHALLENGE tls-alpn-01 tls-alpn-01, http-01 or dns-01
LEMONFIBER_ACME_DNS_PROVIDER none The provider DNS-01 is answered through, by its name below

Choosing acme is agreeing to the authority’s terms. It is the operator accepting the terms of service the directory publishes, as Caddy treats it. The program writes the terms’ address to its log when it first registers an account, and the documentation of every program that implements this page says so where it introduces the mode. Where the directory says an external account is required and no binding is given, the program refuses to start, naming the two settings.

TLS-ALPN-01 (RFC 8737) is answered on the port the program serves on, while it serves. The listener reads each connection’s ClientHello before any handshake; a connection asking for acme-tls/1 and nothing else is answered with the challenge certificate, and every other connection is handed on, still encrypted, to the server that answers it. Renewal costs no downtime and no second port.

HTTP-01 needs port 80, which is plain HTTP. The program opens it when a challenge is pending and closes it once the order is settled; while open it answers GET /.well-known/acme-challenge/<token> for the pending token and 404 to everything else, with no redirect.

DNS-01 is answered through a provider, for a host no inbound connection reaches. A provider does two things, present(name, value) and cleanup(name, value), and declares how long its records take to propagate and how often to look. A _acme-challenge name delegated by CNAME is followed to where it points. Before the authority is told to look, the program asks the zone’s authoritative servers itself and waits until each answers with the value. Providers are named, and their settings spelled, as lego names them, so an operator’s existing configuration carries over:

Provider Settings
rfc2136 RFC2136_NAMESERVER, RFC2136_TSIG_KEY, RFC2136_TSIG_ALGORITHM, RFC2136_TSIG_SECRET_FILE
cloudflare CLOUDFLARE_DNS_API_TOKEN_FILE
route53 AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY_FILE, AWS_REGION, AWS_HOSTED_ZONE_ID
gcloud GCE_PROJECT, GCE_SERVICE_ACCOUNT_FILE
azuredns AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET_FILE, AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP
digitalocean DO_AUTH_TOKEN_FILE
hetzner HETZNER_API_TOKEN_FILE
desec DESEC_TOKEN_FILE
duckdns DUCKDNS_TOKEN_FILE
exec EXEC_PATH: the operator’s program, called as <program> present <name> <value> and <program> cleanup <name> <value>

rfc2136 and exec reach a DNS server the operator runs. A secret is read from the file its _FILE setting names, never from the setting itself, so it never sits in an environment a process listing or a crash report can show.

Renewal. A certificate is renewed when the authority’s ACME Renewal Information (RFC 9773) says to, where it offers it, and otherwise once a third of its lifetime is left, which holds for a certificate of six days as for one of ninety. A failed attempt is tried again after a minute, doubling to six hours, with jitter, and never sooner than a Retry-After or a rate-limit problem from the authority says.

private-ca

The program makes a root of its own the first time it starts in this mode, and issues the certificate it serves from that root.

The root ECDSA P-384, self-signed, valid for ten years, CA:TRUE with a path length of 0, so it signs certificates and no other authority
Its name constraints Critical, permitting exactly the names in LEMONFIBER_NAMES and, for each address, that address alone, and excluding whole each kind of name it is given none of: every address (0.0.0.0/0 and ::/0) where no address is given, every host name (an empty DNS name) where no host name is. A kind with no permitted subtree is otherwise unconstrained, so a root for host names alone would vouch for any address. With both, a root that leaks can vouch for nothing else
Its usage Extended key usage TLS server alone. Directory names, mailboxes and URIs are not excluded: a certificate’s subject is a directory name, and no form excludes every mailbox or URI in every validator, so the usage keeps the root from vouching for mail or a client in the validators that honour usage on an authority
Its key Kept in the state directory, used for nothing but signing this program’s certificate, never served and never logged
The certificate served ECDSA P-256 by default, valid for 30 days, renewed by the program once ten days are left; its common name is a label with spaces, never one of the names, since a validator that finds no host name among the alternative names reads a common name shaped like one as a host name, which an address would be and the root’s exclusion of every host name would refuse
Handing the root over Written to ca/root.pem in the state directory, its SHA-256 fingerprint printed at every start, and served as a file at GET /root.pem over the same TLS, so a person can fetch it and check the fingerprint before installing it

The operator installs the root once on each device that is to connect. From then on every client that trusts it checks the chain as it checks any other, and renewal is the program’s.

A root is replaced when the operator asks (ca replace), when LEMONFIBER_NAMES gains a name the root’s constraints do not cover, and when a year of the root’s life is left. Replacement makes a new root beside the old one, writes it to ca/next-root.pem and prints its fingerprint and, at every start until the switch, that it is waiting to be installed. The program keeps serving from the old root until the operator switches (ca switch), or, where the old root would expire first, until 30 days before it does. A name the old root does not cover is not served until the switch; the program says so rather than serving a certificate that would be refused. After the switch the old root’s key is deleted.

pinned

The program makes a certificate of its own, self-signed, for the names in LEMONFIBER_NAMES, ECDSA P-256 by default, valid for ten years, and prints its fingerprint at every start in the form the companion’s pairing material carries: SHA-256 over the DER encoding, lower-case hex (ADR-0025). A client given the fingerprint pins it and checks it in the handshake. The certificate is not renewed, since a new one would break every pin; it is replaced only when the operator asks (pinned replace), or when LEMONFIBER_NAMES changes, and the program says at start, with the new fingerprint, that every client must be given it again.

Keys

Setting Default Holds
LEMONFIBER_TLS_KEY_TYPE ec-p256 ec-p256, ec-p384, rsa-2048 or rsa-3072, for the certificate served
LEMONFIBER_STATE the program’s own The directory the account, the keys and the certificates are kept in

Every key is generated by the program itself. No two roles share a key: an ACME account key, a private root’s key and a certificate’s key are three keys.

A renewed or replaced certificate is taken by new handshakes as soon as it lands; no connection is dropped for it.

When it fails

Each failed renewal Logged as an error, with the authority’s problem type and detail where there is one and the time left on the certificate in use
Inside seven days of expiry, while renewal is failing Logged as critical once an hour
The health answer GET /health, over TLS, without a credential and carrying no other data: the mode, the certificate’s expiry, when renewal was last tried and how it went; 503 while renewal is failing inside seven days, 200 otherwise

A program that holds a stack credential, as the core does, also raises its own alert. One that holds none, as the MCP server does not, has the log and the health answer.

The state

$LEMONFIBER_STATE/
  account/<sha256 of the directory URL>/key.pem      acme: the account key, one per authority
  account/<sha256 of the directory URL>/account.json acme: its URL and contact
  ca/root.pem, ca/root-key.pem                       private-ca: the root and its key
  ca/next-root.pem, ca/next-root-key.pem             private-ca: a replacement waiting for the switch
  certificates/<first name>/key.pem                  the served certificate's key
  certificates/<first name>/chain.pem                its chain, leaf first
  certificates/<first name>/renewal.json             the mode, when it was issued, when renewal is due, the last attempt and its outcome

The directory is 0700 and every file in it 0600, owned by the user the program runs as; a program refuses to start on a state directory others can read or that another user owns. A file is written beside its final name, flushed and renamed into place, so a crash leaves the old file or the new one and never half of either.

No private key, account key, root key, External Account Binding key or DNS credential is written to a log, an error, the health answer or a value’s printed form, and none is passed to another process except the one exec provider, which is given only the name and the record’s value.

Conformance

Each implementation runs, in its own CI, the same scenarios. Against Pebble, the authority Let’s Encrypt publishes for testing, with its challenge test server answering DNS: first issuance by each challenge, renewal by Renewal Information and by lifetime, an authority requiring a binding with and without one, a rate-limit answer, and an order that fails. Against itself: a private root’s constraints, checked by a real validator for a root given only host names, only addresses and both, accepting each name it was given and refusing a host name, an IPv4 address and an IPv6 address outside them, its replacement and switch, a pinned certificate’s fingerprint matching what a client computes, a renewal swapped without dropping a connection, and a key or a state directory with the wrong permissions. A DNS provider that talks to a remote API is tested against a stand-in of that API.

Requirements

ID Requirement
ARCH-R185 A program lemonfiber builds that serves clients on other machines MUST offer the four modes files, acme, private-ca and pinned, chosen by LEMONFIBER_TLS_MODE, files where both of its paths are set and pinned where nothing is chosen, acme and private-ca only where LEMONFIBER_TLS_MODE names them, MUST refuse to start without LEMONFIBER_NAMES in every mode but files, and MUST NOT answer in plain HTTP anything but the token of a pending HTTP-01 challenge, in any mode.
ARCH-R186 At start the program MUST log which mode it serves and who can check its certificate, and in private-ca and pinned MUST say that an assistant reached through its provider’s servers cannot connect; its documentation MUST say the same of each mode.
ARCH-R187 In files, the operator’s certificate and key MUST be refused at start, by name, where either is unreadable, the key does not match, the certificate has expired, or it does not cover every name in LEMONFIBER_NAMES where that is set, and MUST be read again without a restart when either changes.
ARCH-R188 In acme, the program MUST speak to the RFC 8555 directory LEMONFIBER_ACME_DIRECTORY names, Let’s Encrypt’s by default, checking the directory’s TLS against LEMONFIBER_ACME_ROOTS where given, with External Account Binding where LEMONFIBER_ACME_EAB_KID and LEMONFIBER_ACME_EAB_HMAC_FILE are given; where the directory requires an external account and none is given it MUST refuse to start, naming both. Choosing acme MUST be taken as accepting the directory’s terms, whose address MUST be logged when an account is first registered and stated in the documentation.
ARCH-R189 In acme, the challenge MUST be the one LEMONFIBER_ACME_CHALLENGE names, tls-alpn-01 by default, http-01 or dns-01. TLS-ALPN-01 MUST be answered on the serving port while it serves, answering only a connection whose ClientHello asks for acme-tls/1 alone and handing every other on still encrypted. For HTTP-01, port 80 MUST be open only while a challenge is pending, MUST answer only the pending token’s path, MUST answer everything else 404 with no redirect, and MUST close once the order is settled.
ARCH-R190 DNS-01 MUST be answered through the provider LEMONFIBER_ACME_DNS_PROVIDER names, from rfc2136, cloudflare, route53, gcloud, azuredns, digitalocean, hetzner, desec, duckdns and exec, with the settings this page lists; a CNAME-delegated _acme-challenge name MUST be followed, and the authority MUST NOT be told to validate before every authoritative server of the zone answers with the value.
ARCH-R191 In acme, a certificate MUST be renewed when the authority’s Renewal Information says, where it offers it, and otherwise once a third of its lifetime is left; a failed attempt MUST be retried from one minute doubling to six hours with jitter, never sooner than a Retry-After or rate-limit problem allows.
ARCH-R192 In private-ca, the program MUST make an ECDSA P-384 root valid for ten years with a path length of 0 and critical name constraints permitting exactly the names and addresses in LEMONFIBER_NAMES and excluding every address (0.0.0.0/0 and ::/0) where none is given and every host name (an empty DNS name) where none is, and an extended key usage of TLS server alone; MUST issue the served certificate from it, valid for 30 days and renewed once ten days are left and named by a common name that is none of the names; MUST write the root to the state directory, print its fingerprint at start and serve it at GET /root.pem over TLS; and MUST NOT serve or log the root’s key.
ARCH-R193 A private root MUST be replaced on the operator’s command, when LEMONFIBER_NAMES gains a name it does not cover, and when a year of its life is left: the new root MUST be made beside the old and announced with its fingerprint at every start until the switch; the program MUST serve from the old root until the operator switches or, where the old root expires first, until 30 days before it does; and the old root’s key MUST be deleted after the switch.
ARCH-R194 In pinned, the program MUST make its own certificate for LEMONFIBER_NAMES, valid for ten years, MUST print its fingerprint at every start as SHA-256 over its DER encoding in lower-case hex, MUST NOT renew it, and MUST replace it only on the operator’s command or when LEMONFIBER_NAMES changes, saying at start that every client must be given the new fingerprint.
ARCH-R195 Every key MUST be generated by the program, the served certificate’s ec-p256 by default and ec-p384, rsa-2048 or rsa-3072 where LEMONFIBER_TLS_KEY_TYPE names it; no two of an account key, a root’s key and a certificate’s key MUST be one key; and a renewed or replaced certificate MUST be served to new handshakes without dropping a connection.
ARCH-R196 Every failed renewal MUST be logged as an error with what failed and the time left; inside seven days of expiry while renewal fails it MUST be logged as critical hourly; and GET /health, over TLS and without a credential, MUST answer the mode, the expiry and the last attempt and its outcome, 503 while renewal fails inside seven days and 200 otherwise, carrying no other data.
ARCH-R197 The state MUST be kept in LEMONFIBER_STATE in the layout this page gives, the directory 0700 and every file 0600, owned by the program’s user and written by rename, and the program MUST refuse to start on a state directory another user owns or others can read. No private key, account key, root key, External Account Binding key or DNS credential MUST be written to a log, an error, the health answer or a printed value; a secret setting MUST be read from the file its _FILE setting names; and none MUST be passed to another process but the record name and value given to exec. The settings MUST be spelled and mean as this page lists them in every program implementing it.
ARCH-R198 Each implementation’s CI MUST run the conformance scenarios this page lists, against Pebble and against itself, and each DNS provider talking to a remote API MUST be tested against a stand-in of it.