feat(certs): ship a fixed certificate authority (1.1.0) - #47
Merged
Conversation
Stopping this simulator and starting panelbench rehearses a firmware upgrade on one panel: the hand-carried config brings the serial across and the panel keeps its address and ports. But each install minted its own certificate authority, so the swap presented a new trust anchor and read to a pinned consumer as a panel substitution -- the one event the rehearsal must not simulate. A firmware upgrade does not rotate a panel's CA. The authority is now package data, byte-identical to the one panelbench ships, and `ca.crt` is written from those bytes on every start. Nothing on disk is read to decide trust, so a generated anchor left behind in a persistent `/data/certs` cannot outlive an upgrade. Its SHA-256 is 3cf8c14a78900b8736870c95adcc931cdcb3a51bc3029c96efafd0a4cb790d97, asserted by a test in both repositories because two copies of the same bytes is the one drift no runtime check can catch. The leaf stays per install -- its SAN has to name the address and hostname this install answers on -- and is re-signed whenever it is unfit: SAN stale, expired or near expiry, unparseable, or signed by a superseded authority. That last check verifies the signature rather than comparing issuer names, which is load-bearing: every authority these emulators generated shares one subject and none carries a key identifier, so a name-based check would keep a leaf the packaged CA rejects and leave the emulator serving a chain its own published authority denies -- a state a pinned consumer cannot recover from. Also: atomic writes, an unparseable leaf no longer raises out of startup into a restart loop, a non-IP advertise address is ignored with a warning rather than aborting, and the authority's private key is never written to the certificate directory (one left there by an earlier build is removed). The committed private key is deliberate test material, not a leak. It signs nothing chaining to a real panel, which mints its own CA in firmware and is pinned per config entry; a real panel reporting this fingerprint would be evidence of tampering. See _ca/README.md.
A torn write can leave server.crt from one generation beside server.key from another. Both parse, so the fitness predicate kept the pair, and the mismatch surfaced only as mosquitto refusing to start with a message about its keyfile that names nothing leading back to the cause. The predicate now compares the key's public numbers against the certificate's, which is the only thing that has to agree, and folds the existence check into the same clause.
`_leaf_is_fit` promises every failure comes back False, because it runs at startup under `set -euo pipefail` where an exception is not a diagnostic but an add-on that will not boot. Two paths broke that promise. `_key_matches` reached for `public_numbers()` behind a type-ignore, so a server.key of another algorithm left on disk raised AttributeError instead of forcing a re-sign. Keys are now compared as DER public bytes, which is algorithm-agnostic and needs no narrowing or ignore. `leaf.signature_hash_algorithm` raises UnsupportedAlgorithm for a signature OID cryptography does not recognise, and it was read outside the guarded block. It is now read inside it, alongside the None the Ed25519 family returns.
…he lock The Ed25519 test covered only the branch where `signature_hash_algorithm` returns None. The arm that actually raises -- an OID cryptography cannot name -- had no test, and it is the one whose escape is a boot loop rather than a diagnostic. Built by swapping the sha256WithRSAEncryption OID for an unassigned arc of the same length, so every DER length prefix stays valid: the certificate still parses and the algorithm is rejected only on access. Verified against the pre-fix code, where it raises UnsupportedAlgorithm out of `_issued_by`. uv.lock is regenerated for the 1.1.0 bump, which it had not picked up.
`sed -i ''` is BSD syntax. GNU sed reads `''` as the script and the substitution as a filename, so on CI the hook dies with "can't read s|__version__ = ..." instead of naming the file that drifted. Panelbench hit exactly this once a version went out of sync; here it has been dormant only because these four files happened to agree. The `-i` is dropped rather than branched on platform, because a flag whose spelling differs between the machine you develop on and the machine that gates the merge fails only where you are not looking. Both paths exercised: in sync exits 0 and does nothing, stale repairs the file and exits 1 to force a restage, and no .tmp is left behind.
cayossarian
force-pushed
the
feat/static-ca
branch
from
August 28, 2026 23:31
e5a5f5e to
222f21c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Stopping this simulator and starting panelbench rehearses a firmware upgrade on one panel — the hand-carried config brings the serial across, and the panel keeps its address and ports. But each install minted its own certificate authority, so the swap presented a new trust anchor and read to a pinned consumer as a panel substitution: the one event the rehearsal must not simulate. A firmware upgrade does not rotate a panel's CA.
What
The authority is now package data in
_ca/, byte-identical to the one panelbench ships, andca.crtis written from those bytes on every start — nothing on disk is read to decide trust, so a generated anchor left in a persistent/data/certscannot outlive an upgrade.SHA-256:
3cf8c14a78900b8736870c95adcc931cdcb3a51bc3029c96efafd0a4cb790d97, valid to 2126-01-01, asserted by a test in both repositories — two copies of the same bytes is the one drift no runtime check can catch.The leaf stays per install (its SAN must name this install's own address and hostname) and is re-signed whenever unfit: SAN stale, expired or near expiry, unparseable, key missing or mismatched, or signed by a superseded authority.
The load-bearing detail
The superseded-authority check verifies the signature, not the issuer name. Every authority these emulators generated shares one subject and none carries a key identifier, so a name-based check would keep a leaf the packaged CA rejects — leaving the emulator serving a chain its own published authority denies, which a pinned consumer cannot recover from. That is the upgrade path every existing install takes.
Upgrading raises one repair, and that is the point
Replacing the generated authority with the shipped one raises one "SPAN Panel certificate authority changed" repair. Open it, check the fingerprint above, accept. Doing this now, at an ordinary add-on upgrade, is why the later swap to panelbench needs no repair at all — so upgrade this simulator before moving across, not after.
The committed private key is deliberate
Not a leak; test material, in the
ssl-cert-snakeoiltradition. It signs nothing chaining to a real panel, which mints its own CA in firmware and is pinned per config entry. A real panel ever reporting this fingerprint would be conclusive evidence of tampering — a check the generated-CA arrangement never offered. See_ca/README.md.Also fixed
Atomic writes; an unparseable leaf no longer raises out of startup into a restart loop; a non-IP advertise address is ignored with a warning rather than aborting; the authority's private key is never written to the certificate directory, and one left there by an earlier build is removed.
Testing
25 cert tests, covering a left-behind generated authority, a truncated authority file, a leaf from a superseded CA, a torn key pair, foreign key algorithms, an unrecognised signature OID, and missing package data being fatal rather than self-healing.
Pre-existing unrelated failures on
main(3, inflat_emittertopology andtest_main_logging) are untouched by this branch.