Skip to content

feat(certs): ship a fixed certificate authority (1.1.0) - #47

Merged
cayossarian merged 5 commits into
mainfrom
feat/static-ca
Aug 28, 2026
Merged

feat(certs): ship a fixed certificate authority (1.1.0)#47
cayossarian merged 5 commits into
mainfrom
feat/static-ca

Conversation

@cayossarian

Copy link
Copy Markdown
Member

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, and ca.crt is written from those bytes on every start — nothing on disk is read to decide trust, so a generated anchor left in a persistent /data/certs cannot 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-snakeoil tradition. 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, in flat_emitter topology and test_main_logging) are untouched by this branch.

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
cayossarian merged commit eddc477 into main Aug 28, 2026
2 checks passed
@cayossarian
cayossarian deleted the feat/static-ca branch August 28, 2026 23:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant