Skip to content

feat(certs): the internal CA, and the identifier a certificate cannot carry about itself (T48) - #31

Merged
haiquang9994 merged 7 commits into
masterfrom
feat/t48-internal-ca
Aug 24, 2026
Merged

feat(certs): the internal CA, and the identifier a certificate cannot carry about itself (T48)#31
haiquang9994 merged 7 commits into
masterfrom
feat/t48-internal-ca

Conversation

@haiquang9994

Copy link
Copy Markdown
Collaborator

Roadmap task T48, opening phase 5: this home gets a certificate authority, and one method to
ask about it. Nothing is signed with it yet and nothing installs it — T49 and T50.

What the spec could not have

The security model asked for a subject line carrying the CA's own fingerprint. A certificate cannot
carry a hash of itself: the fingerprint covers the signature, which covers the subject. The identifier
is derived from the public key instead — SHA-256 of the SPKI, first 8 bytes — which is stable
across the whole design and is what a trust store shows a person anyway. Written up in the spec.

Two documents also disagreed about when the CA appears. tls.md said with the first HTTPS site;
security-model.md promises one elevation prompt at first run covering CA, resolver and port grant
together. Only the second keeps that promise, so generation happens at daemon start — the same
finding T45 already made about the resolver, three lines above the block this one joins.

The changes

  • mixengine-platform: write_private — a new primitive. A key made with fs::write and
    restricted afterwards was world-readable for the width of that gap. On Unix the mode goes on the
    open call and truncation happens after; on Windows the ACL is applied to an empty file before a
    byte of key material is written.
  • mixengine-proto: CaStatus / CaStateabsent, present, unusable. The wire type has no
    field a private key could travel in, and a test asserts the field names.
  • mixengine-core: certs::ca — ECDSA P-256, 10 years, pathlen:0, keyCertSign|cRLSign only.
    An authority that is present and damaged is reported, not replaced: replacing it would
    invalidate every leaf already issued and every trust store already holding it.
  • mixengine-daemon: generation at start (a failure warns, never refuses the start), and
    cert.ca_status, which reads and never makes.
  • mixengine-cli: mix cert ca-status, both renderings. Every state exits zero — this reports,
    mix doctor is what carries a verdict.

On the tests

Three claims were checked by breaking the code rather than by watching the tests pass:

  • swapping write_private for fs::write turns all three permission tests red, including the umask
    one — which first asserts its control file came out 0o666, so a machine whose umask makes
    everything private cannot report a false pass;
  • widening pathlen:0 to unconstrained, and adding digitalSignature, turns the constraint test red;
  • the proto round-trip test was added after #[serde(flatten)] fixed a {"state":{"state":…}}
    nesting bug, because the four original tests only encoded — and the client only ever decodes.

mix cert status is asserted to fail, which is the only form in which "T53 still has its name"
is a claim a later task cannot quietly break.

Not in this change

Whether this machine trusts the certificate. There is no such fact in the answer, and a line implying
one would be the client inventing something. --help says so.

…entifier the spec cannot have (T48)

The design for T48, which opens phase 5. Three of its eleven decisions are corrections to the
documents it was written from rather than choices within them.

security-model.md asks for the subject `MixEngine Local CA <short-fingerprint>`. A fingerprint is a
hash of the certificate and the subject is inside the certificate, so no ordering produces it. The
short identifier comes from the public key instead, which is computable before anything is signed;
ca_status still reports the certificate hash, because that is the number a browser shows and
therefore the only one a person can compare against anything.

security-model.md and tls.md also disagree about when the CA appears — "on first use" against step 1
of first-run setup — and only one of them keeps the single-prompt promise that security-model.md
makes four lines earlier. T45 settled the same question for the resolver and recorded why: ask at
daemon start, before any site exists, or one operation becomes two and one prompt becomes two.

And a broken CA is reported rather than replaced. Regenerating would invalidate every leaf already
issued and every trust store the old certificate reached, in answer to nobody. Rotation is T54, and
it exists because it has the steps that would skip.
…ritten was already readable

Nothing in this workspace could write a file with restricted permissions. `DirectoryAccess` has two
methods and both are about directories, and `restrict_to_owner` is applied only to the four private
directories at bootstrap — so the CA private key T48 is about to write would have been protected by
the directory around it and by nothing of its own.

**It is not that capability with a different argument, and the reason is Windows.** `restrict_to_owner`
grants `(OI)(CI)F` — Object Inherit and Container Inherit, which describe what a *directory* hands
down to what is created inside it. `icacls` refuses both flags on a file. The three accounts are the
same three; the grant is not, so this is real code on both systems rather than one call forwarded.

**Each system closes the same window at a different point, because that is where each OS puts it.**
Unix carries the permission in the `open(2)` call that creates the file, and deliberately does not
truncate there: a file that already existed at `0644` would otherwise be emptied and refilled with
the key while still world-readable, so the truncation happens after the mode is settled. Windows
cannot name a file in an ACL before the file exists, so it creates an empty one, restricts that, and
only then writes — nothing worth reading exists inside its window either.

T3b's note on T48 said "keep the order, or restrict the key file itself". The order already holds:
`Paths::bootstrap` strips `certs/` before anything is written into it. But leaving it at that makes
the key's protection a property of a directory whose loss `mix doctor` already has a name for
(`HomePermissionsLost`), and one of those is a fact about this file while the other is an invariant
somewhere else.

**The umask test is the point of the Unix half, and it was checked both ways.** Under a permissive
umask a plain `fs::write` yields `0666`, so asserting `0600` proves the mode came from this code;
without setting the umask the same assertion passes on any machine whose umask happens to be `0o077`
and would keep passing if somebody replaced the body with `fs::write`. The test also refuses to draw
its conclusion unless its control file came out `0666`, so a umask that did not take effect reports
that rather than passing. Confirmed by replacing the body with `fs::write` and watching all three
tests go red before putting it back.
…rately has not got (T48)

`cert.ca_status` and the types behind it. Two of the three shapes here are refusals.

**There is no trust-store field.** Whether an operating system trusts this certificate is a question
about the operating system, answered by machinery T49 builds; shipping a field this build could only
ever fill with "unknown" would be shipping an answer nobody can act on. `DnsStatus` took the same
shape for the same reason at T46 — report the independent facts, refuse to collapse them into a
verdict.

**And there is nowhere a private key could travel.** security-model.md says the key is never copied,
exported by an RPC, or sent to a client; the way that stays true is that no type here has a field to
put one in, and a test asserts on the field names so that adding one has to be argued for out loud
rather than noticed later.

`Unusable` is a closed enum for `ProblemId`'s reason: a client matching on wording is a client that
silently stops matching. An expired authority is deliberately **not** one of its variants — it is
`Present` with a negative `days_left`, because a certificate that exists and parses is not unusable,
and what it needs is rotation, which is T54's.

**`CaStatus` flattens the enum into itself, and that was found rather than designed.** The wrapper
struct is what lets T49 add its field without making a client unwrap a tagged enum — but the outer
field and the enum's own tag are both called `state`, so without `flatten` the tag arrived nested
inside a field of the same name. The first encoding test is what said so.

The round-trip test exists because of that fix rather than beside it. `#[serde(flatten)]` is
deserialised through a buffering map instead of directly, which is exactly where it interacts badly
with tagged enums — and the daemon only ever serialises, so a status that encoded and could not be
read back would have been noticed in a client, on somebody's terminal, and not here.
… carry about itself (T48)

ECDSA P-256, ten years, `CA:TRUE` with `pathlen:0`, `keyCertSign` and `cRLSign` and nothing else, no
subject alternative name at all — an authority is not a server, and a name on one invites something
to accept it as a leaf.

**The subject security-model.md asks for cannot exist.** It names the common name
`MixEngine Local CA <short-fingerprint>`, and a fingerprint is a hash of the certificate while the
subject is inside the bytes being hashed. So the eight characters come from the **public key**,
which is computable the moment the key pair exists and is stable across re-signing the same key —
that second property is worth having on its own, since it makes two certificates over one key
recognisable as one authority. `cert.ca_status` still reports the certificate's hash as the
fingerprint, because that is the number a browser shows.

**Damage is reported and never repaired.** `ensure` creates only when there is nothing at all; a
missing half, an unreadable file or two files that are not each other's are returned as state and
left untouched. Regenerating would invalidate every leaf already issued and every trust store the
old certificate reached, in answer to a request nobody made — and rotation is T54 precisely because
it carries the steps that would skip.

`KeyAndCertificateDisagree` is a real check and not defensiveness: the SubjectPublicKeyInfo in the
certificate is compared against the one derived from the key, because a home restored from a backup
that caught one file and not the other is how they come apart, and unchecked the symptom surfaces
three tasks later as leaves nothing trusts.

**The key is written before the certificate**, so an interrupted generation leaves the half that
`read` can name. The test for that asserts the consequence rather than comparing modification times:
a filesystem's timestamp resolution can be coarser than two writes, and a flaky assertion about
ordering would say nothing about what the ordering is for.

Every assertion about the generated certificate parses it back with `x509-parser` first. Asserting
on the `CertificateParams` that went in would prove that `rcgen` was called and nothing about what
came out — and what T49 installs and T50 signs with is the file. Checked by loosening `pathlen` to
unconstrained and adding `digitalSignature`, and watching the test go red.

`rcgen` brings no new crypto package: its default backend is `ring`, which `rustls` already puts in
this tree. `x509-parser` is the one new package, already named in rust.md's table, at the version
rcgen pins so there is one copy. `Error::Certificate` needed no change in the daemon's wire mapping
after all — that match ends in a wildcard onto `Internal`, which is where a machine that will not
produce a key pair belongs anyway.
…ca_status over the socket (T48)

The certificate authority is made at daemon start, in the block that already asks for port access
and the resolver wiring, under the same rule they state: a failure warns and never refuses the start,
because a home that was not set up is one command away from being set up where a daemon that will
not start leaves the user with nothing.

**The ordering is the point, and it is not this task's finding.** security-model.md promises one
elevation prompt at first run covering the CA, the resolver and the port grant together. An authority
that first appeared when somebody created an HTTPS site would put T49's trust-store install in a
second batch and therefore behind a second prompt — which is exactly what T45 found for the resolver
and wrote down three lines above this block.

So the test that matters runs against a real daemon on a real socket rather than against `ensure`. A
unit test proves the function works; only a started daemon over an empty home proves that something
calls it, and the claim being made here is about when. It checks both the answer and the file,
because they are two claims: the second is where T49 installs from and T50 will sign with.

`cert.ca_status` reads and never makes. A method that generated on demand would move the install
behind a prompt of its own, which is the thing the start-up call exists to avoid.

Two things the plan predicted and one it did not. There are two places that build an `Api` — the
real constructor and a `#[cfg(test)]` fixture — and both needed the field, as T93 found the hard way.
`clippy::result_large_err` fired again: `mixengine_core::Error` is over 128 bytes, so the conversion
to the wire error moved inside the `spawn_blocking` closure rather than sitting after the `await`,
which is the boundary every other module in this crate already converts at.

Not predicted: `serve` does not have the `Api` in scope where this belongs, so the block builds a
`Certificates` from the `paths` it already holds. It carries nothing but a `PathBuf`, so that is
cheaper than moving the call to where the `Api` exists — which would be after the endpoint is bound
and therefore after a client could already ask.

The eight `dns::server` unit tests fail in a full parallel run on this machine and pass when run as a
group, which is the ephemeral-port contention already recorded for them here; nothing in this change
binds a port. CI is what answers that.
…T48)

Both renderings over `cert.ca_status`, and one deliberate absence in each.

**`ca-status` rather than `status`.** tls.md gives the short name to the per-site diagnostics with a
live TLS handshake, which is T53, and names this command's siblings `ca-uninstall` and `ca-rotate`.
Taking it here would have meant renaming later or giving one command two unrelated jobs — so a test
asserts `mix cert status` still *fails*, which is the only form of that claim a later task cannot
quietly break.

**Nothing on the screen says whether this machine trusts the certificate.** There is no such fact in
the answer, and a line implying one would be the client inventing something — the rule CLAUDE.md
states as "a client only renders what the daemon returns". The `--help` text says so in a sentence,
without naming the task that will answer it: clap prints every line of a doc comment, and a roadmap
number means nothing to whoever typed `--help`. It sits in a plain comment beside it instead.

Every state exits zero, `unusable` included. This command reports; `mix doctor` is what carries a
verdict, and a reporting command with a failing exit is one nobody can put in front of an `&&`
without stopping to think.

`days_left` renders negative as "expired N days ago" rather than clamping. An expired authority is
the one state here worth acting on, and "in 0 days" would be the screen hiding it.

The JSON test repeats `cert_api`'s own assertion about field names — that a private key has nowhere
to travel — deliberately, at the place a person actually pipes this into something.
…del asked for cannot exist (T48)

Phase 5 opens at 1 of 7. Three of the changes here are corrections to the documents T48 was written
from rather than records of what it built.

security-model.md named the common name `MixEngine Local CA <short-fingerprint>`. A fingerprint is a
hash of the certificate and the subject is inside the bytes being hashed, so no ordering produces it;
the identifier now comes from the public key, which is computable before anything is signed and
survives re-signing the same key. The certificate hash is still what `cert.ca_status` reports as the
fingerprint, and the document now says which of the two is which rather than leaving a reader to
assume they are one.

It also said the CA is generated "on first use" while tls.md put it at step 1 of first-run setup with
the trust-store install batched into a single prompt. Both cannot hold, and the promise being broken
is four lines above the sentence breaking it. T45 settled the same question for the resolver and
recorded why, so this follows it rather than re-deciding it.

And the key-protection bullet claimed the directory was what closed the key off. That is now half of
it: the file carries its own permission, applied as it is created, because relying on the directory
alone makes the key depend on something `mix doctor` already has a name for losing.

T48's own entry records what it did not do, which is the part a later task needs: no trust-store
field (T49), no doctor check (T54 owns what repairing it means), and `mix cert status` left free for
T53's per-site handshake.
@haiquang9994
haiquang9994 merged commit 676d783 into master Aug 24, 2026
10 checks passed
@haiquang9994
haiquang9994 deleted the feat/t48-internal-ca branch August 24, 2026 16:18
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