feat(certs): the internal CA, and the identifier a certificate cannot carry about itself (T48) - #31
Merged
Merged
Conversation
…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.
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.
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.mdsaid with the first HTTPS site;security-model.mdpromises one elevation prompt at first run covering CA, resolver and port granttogether. 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 withfs::writeandrestricted afterwards was world-readable for the width of that gap. On Unix the mode goes on the
opencall and truncation happens after; on Windows the ACL is applied to an empty file before abyte of key material is written.
mixengine-proto:CaStatus/CaState—absent,present,unusable. The wire type has nofield 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|cRLSignonly.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), andcert.ca_status, which reads and never makes.mixengine-cli:mix cert ca-status, both renderings. Every state exits zero — this reports,mix doctoris what carries a verdict.On the tests
Three claims were checked by breaking the code rather than by watching the tests pass:
write_privateforfs::writeturns all three permission tests red, including the umaskone — which first asserts its control file came out
0o666, so a machine whose umask makeseverything private cannot report a false pass;
pathlen:0to unconstrained, and addingdigitalSignature, turns the constraint test red;#[serde(flatten)]fixed a{"state":{"state":…}}nesting bug, because the four original tests only encoded — and the client only ever decodes.
mix cert statusis 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.
--helpsays so.