feat(certs): this machine's trust store on three systems, and the four things only a real one could answer (T49a) - #32
Merged
Conversation
…(T49a) The roadmap wrote T49 as one task: the system trust store *and* the Linux NSS databases, all "batched with T42 and T45 into the single first-run elevation prompt". Half of that sentence is not true of the other half. `~/.pki/nssdb` and `~/.mozilla/firefox/*/` belong to the user — writing to them needs no root, raises no prompt, and cannot be batched into one because there is no batch to put them in. So the two halves differ in the property that decides which binary the code lives in, which tests can run unprivileged, and whether the work touches the elevation queue at all. T49a is the system store on three systems; T49b is NSS. After T49a alone, Chrome and Safari on macOS and Chrome and Edge on Windows see a trusted certificate, which is what makes it a task rather than half of one. **T49b starts from a measurement this task made, so it does not start from the specification.** On a stock Ubuntu 24.04 `update-ca-certificates` is present and `certutil` is not — it ships in `libnss3-tools`. `tls.md` names `certutil` as the mechanism and says nothing about a machine that has not got it. Three findings in the design, each of which changed what will be built: **The removal is the direction that can do damage, so it carries no fingerprint.** An install is close to harmless — a daemon compromised badly enough to forge one already holds the CA key and can sign anything. A removal that named a certificate by hash could take out the root that validates Windows Update. What travels is T48's key-id, eight lowercase hex characters, which cannot describe a corporate root at all. This is `ResolverPlan`'s own argument one capability along: the field an attacker would abuse is not validated, it is absent. **Two dependencies were costed and both refused.** `mixengine-elevate`'s closure is 18 crates and CI diffs it; `x509-parser` is 29 with 7 in common, and `sha2` is 8 with none. Costing `sha2` is what found that the check it was for — recomputing the key-id from the public key — refuses nothing, since an attacker sets the name to its own key's identifier. The checks are hand-written against a DER reader that only knows how to say no, and the key-id earns its keep in the removal instead. **The trust is machine-wide while T48's key protection is per-user.** That is inside the trust model `security-model.md` already states, but nobody had written down that it was deliberate. It will be.
…eliberately has not got (T49a) `TrustPlan` and `TrustTarget`, and **not** the two operations that carry them. The plan said this commit would add both; the codebase said otherwise, and it was right. `PrivilegedOp::ALL` is documented as what the *installed helper* can do — a daemon reads it to find out without spending a prompt to discover it by failure — and a test enumerates every variant by hand and refuses to let `ALL` and the enum drift apart. So a commit adding the variants has to either claim the helper can install a certificate before it can, or fail that test. The operations therefore land with the helper that implements them, and this commit is the types they will carry. **`TrustPlan` carries the certificate's DER, never a path to it.** `ResolverPlan` already wrote the argument out: what the helper can know is compiled into the helper, and a path is somebody else choosing which file root opens after root has decided to trust the request. The store, the file name on Linux and the update command are all constants in `mixengine-elevate`. **`TrustTarget` carries a key-id and has no fingerprint field, which is the security decision of this task.** The two directions are not symmetrical. An install is close to harmless: a daemon compromised badly enough to forge one already holds the private key of the authority this machine trusts, and can sign any certificate for any name without installing a second root. A removal that named a certificate by its hash could take out the root that validates Windows Update, or an organisation's own root — through the audited binary, under the user's own Allow click, and that is damage the attacker could not otherwise do. So eight lowercase hex characters travel and nothing else does. A corporate root cannot be described by one, so the operation cannot be aimed at it — `ResolverPlan`'s argument one capability along: the value an attacker would abuse is not validated, it is absent. A test asserts the encoded form does not contain the word. This is also where T48's key-id turns out to be worth something. The T49a design first put it on the install side, as a check the helper would recompute from the public key; costing that check found it needed SHA-256 in the helper — 8 more crates, measured, none of them already there — and that it refuses nothing, since an attacker sets the name to its own key's identifier and passes. Naming an authority for removal is what the identifier is actually good for.
…2 crates it is instead of (T49a)
`mixengine-elevate` has to decide whether the certificate it was handed is one MixEngine made before
it puts it in a machine-wide trust store. The obvious way is `x509-parser`, which is already a
workspace dependency. Measured, both built on their own:
mixengine-elevate today 18 crates
x509-parser alone 29 crates
in common 7
So 18 to about 40 — a general-purpose ASN.1 framework with two proc-macro crates of its own, plus
`nom`, `num-bigint`, `time` and `oid-registry`, into the one binary that runs as root and whose
closure `.github/elevate-dependencies.txt` pins, CI diffs, and opens by saying that adding a line is
a security decision.
It is refused, because the job is not to *understand* a certificate but to walk to a handful of
places and say no to everything else. `trust::der` is that reader and `trust::check` is D4's table
on top of it. Between them they are smaller than the crate list they replace.
**Nothing in the reader may panic**, and that is a property rather than a hope: a panic in the helper
leaves no response file, which is the protocol, and a daemon cannot tell that from a helper that
never started. There is no indexing in the module at all — every read is a `get` — and two tests feed
it every prefix of a valid encoding, every single byte in three positions, and every truncation of a
real certificate. Replacing one `get` with an index turns both red, which was checked rather than
assumed.
**The check is not a security boundary and the file says so in its first line.** A daemon compromised
badly enough to forge one of these already holds the private key of the authority the machine trusts
and can sign anything for any name; a second root buys it nothing. What the check is for is keeping
the set of certificates an install could ever have created *enumerable*, so that `ca-uninstall` (T54)
and uninstall (T87) can be sure they got all of it.
Three sabotages, each naming the test that must go red and each confirmed:
- `check_extensions` returning `Ok(())` — the SAN, the key-usage and the pathlen refusals all fail.
- `key_id` returning its input unconditionally — the name-family refusal fails.
- one `get` in the reader replaced by an index — both panic tests fail.
**And one thing the tests found rather than confirmed.** `rcgen`'s default validity is 1975 to 4096.
The first fixture left it alone, so the accept-path tests failed with "an authority valid for 2121
years" — the reader was right and the fixture was not a certificate T48 would ever produce. The
fixture now sets the ten years T48 sets, and a comment says why, because the next person to write one
of these will reach for the defaults too.
`ours`, `is_key_id` and `subject_of` are `pub` and not `pub(crate)`: the helper is a different crate.
That is not a widening for convenience — it is what the module is for.
…he question CI had to answer (T49a) The `TrustStore` capability: which mechanism this machine has, whether it already holds a given certificate, and what would have to be applied or removed. Reads only — the write needs a token the daemon does not have. **One commit rather than the two the plan drew**, and for the reason the plan's first task also collapsed: a trait method has to be implemented by all three real hosts and by the mock in the commit that declares it, or nothing compiles. A trait with a stub implementation returning "no store" would compile and would be a lie on every machine that has one. **Comparison is exact DER bytes on all three**, never a subject and never a hash — the T49a design's D6. A subject match would claim another home's authority as this one's; the SHA-1 property a store offers to search by is a different value from the SHA-256 `cert.ca_status` reports, and carrying two hashes for one identity is how they come apart. Per system: - **Windows** enumerates `LocalMachine\Root` through `windows-sys`, not `certutil.exe`. Every unsafe call this task makes is in `windows/store.rs`, so somebody auditing what this project does with raw pointers reads one file. The handle closes itself in `Drop` and a certificate context never leaves the enumeration, because both are easy to miss on an early return. - **macOS** runs `security find-certificate -a -p`. An empty keychain exits non-zero, which is a true answer to the question being asked and must not become a failure that stops a start. - **Linux** is two families and neither is the platform, detected by probing for the directory as `tls.md` asks rather than by reading `/etc/os-release`. It checks **two** things: the anchor file holds our bytes, *and* the generated bundle does. A file written where the refresh command never ran is a certificate nothing on the machine accepts, and reporting it as installed would report a home as working while its HTTPS does not. **`tests/trust.rs` is deliberately not `#[ignore]`d**, which is the whole mechanism by which the two assumptions this design could not measure get measured. `testing.md` rule 1 gates tests that *touch* a trust store; these only read one. Being in CI's ordinary `test` job on all three runners, under no administrative token, is what turns "reading a store needs no privilege" from a comment into a fact — and if it turns out false, the daemon's producer and `mix doctor`'s check both need a different shape. Windows answers yes locally already; macOS is what CI is for. **`pem` is taken where `x509-parser` and `sha2` were refused, and the measurement is why.** Two crates — itself and `base64` — against 22 and 8, and what it replaces is base64 padding rules, which are wrong in ways nobody notices until a certificate of the wrong length arrives. It is **optional, declared under `cfg(unix)` and named by `host` alone**, which is `dbus-secret-service`'s arrangement and its reasoning: Windows carries DER end to end and never sees an envelope, and the helper does not read a store *yet*. So `.github/elevate-dependencies.txt` does not move in this commit. Declaring it unconditionally would have put two crates in a root binary's closure before anything in that binary used them, which is a line nobody could argue for in the file that asks for an argument — and `cargo check -p mixengine-elevate` on Linux said so, in three dead-code warnings the Windows build could not produce. The mock's default is a machine with **no** store holding nothing, on `mock/resolver.rs`'s stated reason: every suite written before this was written against a home nothing trusts, and a default that quietly held the authority would rewrite what those suites are about. `cargo doc` caught the one thing the other two gates did not: a link to `PrivilegedOp::TrustCaInstall`, which does not exist yet because the operations were deferred to the commit that implements them. Rustdoc runs with `-D warnings` at the end of CI's test job, and clippy being clean says nothing about it.
…n cannot be aimed at (T49a) The two operations and the writers under them, landing together because `PrivilegedOp::ALL` is documented as what the *installed helper* can do and a test refuses to let it drift from the enum. A commit adding the variants earlier would have had to claim the helper could install a certificate before it could. **The install and the removal are not the same kind of thing, and the code is arranged around that.** An install is close to harmless: a daemon compromised badly enough to forge one already holds the private key of the authority this machine trusts and can sign anything for any name, so a second root buys it nothing. The shape check exists so `ca-uninstall` (T54) and uninstall (T87) can enumerate everything an install could ever have created — an unconstrained one could leave a root called anything at all, which nothing would find again. A removal is not harmless. One that named a certificate by hash could take the root that validates Windows Update out of this machine, through this audited binary and under the user's own Allow click. So the wire type has no fingerprint field, and eight lowercase hex characters is the whole of what travels — checked in the helper before a store is opened, and checked **again** in the platform layer against every certificate the store hands back, because a certificate sitting under a MixEngine name is not proof that MixEngine put it there. Per system, and none of them interchangeable: - **Windows** adds with `CERT_STORE_ADD_NEW`, not `REPLACE_EXISTING` — a store entry carries friendly names and trust properties beside the encoded certificate, and none of those are ours to overwrite. `ERROR_ALREADY_EXISTS` is the `AlreadyDone` answer. The removal duplicates the context before deleting it, because `CertDeleteCertificateFromStore` frees what it is given and deleting the one the enumeration is standing on would leave the next step a dangling pointer. - **macOS** hands `security add-trusted-cert` a file, and **the path is the helper's own**: one fixed command with a constant argument vector is the rule T42 set with `pfctl`, and the DER coming from the request does not make its path do so. - **Linux** writes the anchor and runs one of two fixed refresh commands, reading first so that an anchor already holding exactly this is `Unchanged` rather than a rewrite plus a refresh for nothing. **One thing Windows could not have told me.** The macOS writer first used `tempfile` for that handoff file — and `tempfile` is a dev-dependency, so it would have compiled here and failed on the one system that runs the code. It writes into the root-owned audit directory instead, under a fixed name, which is better than a dependency for a reason beyond the crate count: nothing about the path comes from a request, and no unprivileged account can swap what is at it between the write and the read. `.github/elevate-dependencies.txt` gains `pem` and `base64` here — the commit where a binary that runs as root first uses them, rather than the one where the crate was declared. Two crates, measured against the 22 `x509-parser` would have cost and the 8 `sha2` would, both of which this task refused. The system suite installs a real generated authority, installs it again, removes it, and removes it again — **one test, not four**, so a runner is never left holding a certificate a later job would find, and the removal runs whatever the install answered rather than after an assertion that could abort first. Two more prove the refusals survive the whole path under a real administrative token: a unit test shows `trust::remove` says no to `"DigiCert"`, and only this shows it still says no while the process actually holds the privilege to have done the damage. `rcgen` is a plain dev-dependency and not an optional one behind a feature: cargo does not allow optional dev-dependencies, which the manifest said the moment it was tried. One existing fixture changed. `ops.rs` used `"op": "trust-ca-install"` as its example of an operation this build has never heard of, and T49a made it real. It is `"firewall-allow"` now, with a note saying why — a name that stays unknown is the whole point of that test.
…d mix doctor says when it has not (T49a) The producer, the check and the repair. `require_trust_store` runs at every start, immediately after the block that makes the authority and for the reason that block is there: `security-model.md` promises one elevation prompt at first run covering the CA, the resolver and the port grant together, and an install that first appeared when somebody created an HTTPS site would be a second batch behind a second prompt. Reading a store costs no privilege on any of the three systems, which is what makes asking on every start affordable — and what notices a store an operating-system update or another account cleared. A probe that fails asks for nothing and warns, `require_resolver`'s rule: the helper is not the authority on what a store holds, so a read that failed has said nothing about what to ask for. **`ProblemId::CaNotTrusted`, where T48 declined to add a condition.** The two answers differ because the conditions do. There the condition was a damaged authority and repairing it would have meant regenerating — destructive, invalidating every leaf issued and every store holding the old certificate, and T54's decision to make. Here the repair is *ask again*, which is exactly what `ResolverNotWired` and `PortAccessMissing` already do through the same `Enqueue` arm. A machine with no store MixEngine knows how to write is a `Note` and not a problem, on the reasoning the resolver check already gives about `hosts_only`. **Five existing tests went red, and the honest fix was not to filter them green.** `a_fresh_home_is_not_degraded` asserted a fresh home has nothing waiting. That is now false, and false *correctly*: the producers that fill first-run setup's single grant run at start, so a fresh home on a machine with a trust store has that install waiting and is — by this project's own definition, "not zero means degraded" — degraded until somebody grants it. `elevation.rs`' own header already said so: a fresh install nobody ever grants stays degraded, and that is the correct behaviour. The test now asserts what it can still honestly assert — that the count and the list are one answer, and that no question is queued twice. Two more need an **actually** empty queue to mean what they say, so they empty it rather than filtering. "A grant refused because the queue is empty" is a different claim from "a grant refused because the queue holds nothing of a particular kind", and for that test the difference is whether it can reach a real prompt — the one thing that suite must never do. The other two now find their own row by kind instead of by index, because which position a start-up producer takes is not what they are about. And the CLI's doctor test counted eleven checks by hand. That count is the guard T47a built to stop a check appearing without anybody deciding what it means, so it is updated rather than loosened. `mixengine_core::certs::ca::der` is new: the daemon has PEM and the wire carries DER, and the decoding belongs beside the code that wrote the file. A second decoder elsewhere would be a second answer to "which bytes are the certificate", and the helper compares what a store holds against exactly these. 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; nothing here binds a port.
… only have answered "unknown" (T49a)
`cert.ca_status` grows the field T48 left out on purpose, recording that one it could only have
filled with "unknown" is not an answer. It is answerable now, and the four states are `installed`,
`not_installed`, `no_store` and — still — `unknown`, which here means *asked and could not find out*
rather than *never asked*. Rendering that as "no" would be the client inventing an answer.
**Nested rather than flattened, and the encoding test is what decided it.** The first shape stuttered
— `{"trust": {"trust": "not_installed"}}` — which is the same defect T48 hit, caught the same way, by
a test that encodes. There the fix was `#[serde(flatten)]`; here it cannot be. `Trust::NotInstalled`
and `CaState::Unusable` both spell their reason `because`, so flattening would let an unusable
authority on a machine with no store lose one of the two sentences with nothing to say which. The
inner tag is `state`, a client reads `status.trust.state`, and the test that pins this is exactly the
`unusable` + `no_store` pair that made the collision real rather than theoretical.
The screen says **which store**, because "trusted" and "in this machine's own store, for every
account on it" are different claims and only the second is what happened. Exit code stays zero in
every state, on T48's reasoning: this command reports and `mix doctor` carries the verdict.
**Eleven existing tests across three suites went red, all for one reason**, and none of them was
filtered green. A started daemon now queues what first-run setup needs, so any test that wrote a
constant for the queue's length was writing down a property of the machine it ran on.
`nothing_was_granted` asserted the queue held one row. It now compares against the count measured
right after its own fixture built the queue — which is the stronger version of what it always meant:
*the queue is exactly what it was*, not *the queue is one row long*. `granting_with_nothing_waiting`
empties the queue first, because it is about the daemon's refusal when there is genuinely nothing and
filtering would have let the command reach a real prompt, which that suite must never do.
And the doctor's check count is asserted by hand in **two** places, not one — the CLI's suite and the
daemon's. Both are the guard T47a built so that a check cannot appear without somebody deciding what
it means, so both are updated rather than loosened.
`Certificates` gained a second constructor rather than an `Option` threaded through the old one: the
start-up path has `Paths` and no `Api` yet and does not need a store read to make an authority, while
the one the API holds always can. The `Option<Arc<dyn Host>>` inside says which is which, and every
branch that cannot ask prints why instead of guessing.
…things tls.md asked for that were not built (T49a) T49a ticked, and four documents corrected against what building it found. **`security-model.md` gains the asymmetry nobody had written down.** T48 protects the private key at the scope of one account — `0600`, a DACL naming the current user, applied as the file is created. T49a grants the trust at the scope of the whole machine. On a machine with more than one person that means account B's browser trusts an authority whose key lives in account A's home, and account A can mint a certificate for any name. That is inside the trust model this document already states, and the alternative is worse: a per-user store exists on Windows and macOS and **not on Linux at all**, where the machine-wide anchors directory is the only one there is. What was missing was anybody saying it was deliberate. **Three things `tls.md` asked for and one of them cannot exist.** *"Removal: delete by fingerprint"* is the one. A removal that could name an arbitrary certificate could take the root that validates Windows Update out of a machine — through the audited helper, with a valid signature, under the user's own Allow click. That is damage a compromised daemon could not otherwise do, and unlike the install direction there is no argument that it already can. So the wire type has no fingerprint field and carries T48's eight-character key-id instead, which cannot describe a corporate root. *"`certutil` fallback"* on Windows was not built. The store API is four calls; spawning a process from a context holding an administrative token is the larger surface, not the smaller one. *"Record `ca.installed_in_trust_store`"* is not done either, and this is the one worth stating as a rule rather than a choice: **a stored flag is a claim an operating-system update, another account, or a person with `certmgr` can falsify without MixEngine hearing about it.** Reading the store costs no privilege on any of the three systems — measured in CI's ordinary test job, not assumed — so asking is cheaper than remembering and cannot go stale. **`platform-abstraction.md`'s `TrustStore` row** said `certutil -addstore ROOT` and named the NSS databases in the same cell as the system stores. It now says what each system does, why macOS shells out where the keyring goes through Security.framework, and that NSS is T49b and needs no privilege. The roadmap entry records the two dependencies that were costed and refused, and the one that was costed and taken — because the file those numbers live in asks for an argument, not for a preference.
…he constant a real store had to correct (T49a) Three defects the `system` job found, all of them in code no local check could reach: the elevated-only build of `mixengine-platform` on macOS and on Windows. The `lint` job runs `cargo check -p mixengine-elevate` on Ubuntu only, and Ubuntu is the one of the three where every line of this compiled. **macOS did not compile at all.** `certificates()` is gated `any(host, elevated)` and the `Result` alias it returns is imported under `host` — so in the helper's build, which is the only build that ever *writes* a keychain, a bare `Result` was `std`'s and took two parameters. Written out as `crate::Result` with the reason beside it, because the next person to add a function to that file will be choosing between the same two spellings. **Windows compiled and then answered `Failed` to its second install.** `CertAddEncodedCertificateToStore` reports through `SetLastError`, but what it sets is the crypto layer's `HRESULT` and not a Win32 code, and the two do not overlap: `CRYPT_E_EXISTS` is `0x80092005` where `ERROR_ALREADY_EXISTS` is 183. The wrong constant is invisible on a first install and turns every one after it into a failure — which is the whole reason the round trip in `mixengine-elevate/tests/system.rs` asks for the second answer as well as the first, and it is the only thing in T49a that a unit test could not have found. A real store had to be asked. **And two dead-code warnings that only those builds carry.** `each_certificate` is the producer's half and the helper never calls it; `pem::decode` is Linux's, in both directions, where macOS only ever reads the keychain's whole list. Both are now gated to the builds that use them, and `CertFreeCertificateContext` moved into the one function that frees anything. **What did not change is the budget or the bench.** `bench (ubuntu-latest)` failed in the same run on a median of 10594 ms against 10000, and it is not this branch: the same job on the last three green master runs measured medians of 7542, 8817 and 8056 ms with individual rounds between 1693 and 13410 — an eightfold spread inside a single run, and a cold start in this one that was faster than every warm round after it. A budget widened on the branch that tripped it would be a measurement edited to let a change through.
…to each other (T49a)
The cfg that stopped `pem::decode` being dead code on macOS made `decode_all`'s intra-doc link to
it unresolvable there, and `rustdoc` on macOS is the only place that can see it: on Linux both
functions exist, and on Windows neither does — the module is `#[cfg(unix)]`.
**The rule is the general one, not the patch.** `decode` is Linux's and `decode_all` is macOS's plus
every `host` build; they are gated onto different systems on purpose, so a link from either to the
other is a link that cannot resolve on the OS where only one of them is compiled. Both notes now
name the other function instead of linking to it, and say why.
`ci.yml` already argues this exact case — "every OS directory in mixengine-platform is mapped onto
`sys` by `#[path]`, so a host-only run leaves two thirds of that crate unbuilt, and the broken link
that created T2a was in the macOS half" — which is why `rustdoc` runs once per OS in `test` rather
than once in `lint`. That is the check that caught this, working as designed. What it costs is a
whole CI run per discovery, and that half of `mixengine-platform` turns out to be reachable from
this machine after all:
cargo clippy -p mixengine-elevate --target aarch64-apple-darwin -- -D warnings
cargo clippy -p mixengine-platform --target aarch64-apple-darwin --features host -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc -p mixengine-platform --no-deps \
--document-private-items --features host --target aarch64-apple-darwin
Not the workspace and not `--all-targets`, for the reason that comment gives: `libsqlite3-sys` and
`ring` need a C toolchain no Windows host has. These two crates need none — the root-running
binary's closure has no C build script by design — and the third command reproduces the error this
commit fixes, checked by putting the link back and watching it fail.
…es it cost to find out (T49a) `system (macos-latest)` printed `running 8 tests` and then nothing at all for twenty minutes, until the job's own timeout cancelled it. Nothing failed — every other job in the run was green, including `system` on Windows and on Ubuntu. libtest sorts by name, so the first of the eight is `an_authority_goes_into_this_machines_trust_store_and_comes_back_out`, and a `security` this task spawned did not come back. **Which call, and why, is the thing the run could not say — and that is the defect.** Two properties this code should have had from the start, both of them true regardless of what turns out to be blocking: **`stdin` is `/dev/null`.** `security` asks for a password when it wants one. It is called here from a process with no terminal to ask at: under `sudo` in CI, and in production from behind an OS elevation prompt, in front of a user who has already clicked Allow and is now looking at nothing at all. `Command::output()` pipes stdout and stderr and **inherits stdin**, so whatever the test binary was given, `security` could read from. A question nobody can answer has to fail, not wait. **And the wait is bounded — thirty seconds, then killed.** A privileged helper that blocks forever is worse than one that fails: it is holding this crate's trust lock while it does it, and the operation it was spawned for is the one standing between a first run and a working machine. The error now carries the whole argument vector, so the next run says which verb blocked instead of saying nothing for twenty minutes. The fix is also the instrument. Two smaller things fall out of doing it properly rather than reaching for `wait_timeout`, which is a crate, and this binary's dependency closure is diffed by CI: - **The pipes are drained on threads.** `find-certificate -a -p` prints every certificate the machine trusts — a couple of hundred kilobytes against a 64 kB pipe buffer. A loop that polled for exit without reading would block the child on its own output and then report that deadlock as a timeout, which is a worse lie than the hang. - **The output is collected with a grace period, not a `join`.** End of file means every holder of the write end has gone, and a grandchild would be one — a join would be one more unbounded wait inside the function that exists to remove them. `run` also names the verb in its failure now: `security` says "The specified item could not be found in the keychain" for several different requests, and which one was made is the half of that sentence a person needs. **What this does not claim.** It does not fix the hang — it makes the hang report itself. If what blocks is `add-trusted-cert` reaching for an authorization dialog that cannot be shown, the answer is a different mechanism and a revision to the T49a design's D6, not a longer timeout. That is a decision to take with the next run's message in hand rather than from a guess about somebody else's operating system.
…d the store CI never looked at (T49a)
The bounded wait worked: `system (macos-latest)` now finishes in 30.6 seconds instead of being
cancelled at twenty minutes, and it has narrowed the fault to one call.
**The install is fine and the removal is not.** `trust-ca-install` answered `Applied`, a second one
answered `AlreadyDone`, and `trust-ca-remove` sat there until the deadline killed it — one timeout,
which is the whole of the suite's 30.6 seconds. So `security add-trusted-cert -d -r trustRoot -k
/Library/Keychains/System.keychain` runs headless as root, and `security remove-trusted-cert -d`
does not come back.
**What the run still could not say is why, and that is a defect of its own.** The outcome read
`Failed { message: "cannot run security to change the System keychain" }` — the action, with the
cause cut off, because `Error::Os` renders as `cannot <action>` and keeps the operating system's own
words as its `#[source]`. Every `Failed` this helper produces was throwing that half away:
`hosts`, `port_access`, `resolver` and `trust` alike, four sites, one mistake. They now go through
`mixengine_proto::flatten`, which is the same function `mix` has always used at its own boundary and
which exists to answer exactly this — one sentence, causes appended, nothing printed twice. A
privileged operation that fails on a user's machine and reports only which verb it was attempting is
not something anybody can act on.
**And the `system` job now prints what the trust store holds afterwards**, beside the resolver
configuration and the packet filter it already printed for this reason. Two different questions get
asked of macOS here and nothing had ever printed either answer: whether the certificate is *in* the
keychain, which is what `TrustStore::probe` measures, and whether the machine actually *trusts* it,
which is what the operation is for. `add-trusted-cert` succeeding does not by itself establish the
second, and if it turns out it did not, then the probe's definition of "installed" is wrong on macOS
and D6 needs revisiting rather than the removal needing a longer timeout. The diagnostic's own
`security` calls are bounded and given no console — a step added to diagnose a hang must not be
capable of the hang.
This does not fix the macOS removal. It makes the next run say which of the two possible faults it
is.
… is the task it became (T49a)
The diagnostic answered the question it was added for, and the answer splits the macOS half in two
rather than condemning it.
```
--- certificates in the System keychain ---
"labl"<blob>="MixEngine Local CA 5ec0de5a"
--- admin trust settings ---
Number of trusted certs = 1
Cert 0: MixEngine Local CA 5ec0de5a
Number of trust settings : 0
```
**The install is complete, not partial.** The certificate is in `/Library/Keychains/System.keychain`
*and* in the admin trust domain, and an empty settings array is macOS for "trusted as a root for
every use" — which is exactly what `-r trustRoot` asks for. `dump-trust-settings -d` also read the
admin domain under `sudo` without pausing for anything, so the fault is not "writing trust settings
needs an agent". D6's macOS write row is measured and correct, and `TrustStore::probe`'s definition
of installed is not wrong on macOS.
**One verb does not return, and two explanations are left that this task cannot tell apart.**
`remove-trusted-cert` takes no keychain argument, so either it searches the default list — which
under CI's `sudo -E` is the *invoking* user's, whose login keychain is locked, making the hang an
artefact of `sudo -E` rather than of the product — or it goes through the admin-domain API and asks
for a right that cannot be granted with no agent to display, in which case the mechanism has to
change and D6 changes with it. Telling those apart is roughly ten minutes of CI per guess, about
somebody else's operating system, and that is not how this decision should be made.
So it becomes **T49c**, written into phase 5 where it belongs rather than appended, with the
evidence and both explanations. It records one dead end in advance: `security delete-certificate`
takes an explicit keychain and would not hang, but macOS evaluates admin trust settings **by
certificate hash**, so deleting the certificate alone leaves the machine still trusting it — an
uninstall built on that would report a removal it had not performed, which is worse than one that
fails.
**What T49c blocks is T54, and nothing that ships.** D5 deliberately gave the removal no producer;
T54 and T87 are the producers. Every other part of T49a is proved on all three systems: install,
idempotent install, the refusals, the unprivileged reads, and the whole removal round trip on
Windows and Linux.
**The test measures the removal on macOS and does not require it.** It must answer either a change
or this known failure, so a `Refused` or a different message is still a failure here — and a machine
that starts answering properly makes the note wrong in a way somebody will notice. It also says out
loud that it has left the certificate behind, with the two commands to look, rather than leaving
that for someone to discover.
… T49c is open (T49c) `ci.yml` runs ten jobs across three operating systems and the macOS `system` leg finishes last of them. T49c is a question with more than one guess left in it, and asking each guess through that costs ten minutes. This is the macOS leg alone, started by hand, and its default mode compiles nothing — the probe needs no toolchain at all. It asks every surviving explanation in one run rather than one per run, each command under its own alarm so that one that hangs spends twenty seconds and no more, and each exit status printed rather than inferred from whether the step went red. The first case is the discriminator the rest cannot be read without: if a plain `sudo security remove-trusted-cert -d` answers here, then the fault is not the command but something about how the helper runs it, and the whole search moves. Temporary, and it says so at the top: when the removal has a mechanism this file goes, and what it proved is written into the T49a design and the roadmap where the next person will find it.
…he default branch (T49c)
…m worth asking about (T49c) A1 answered, and it is the one the rest could not be read without: `sudo security remove-trusted-cert -d probe.pem` hung for its full twenty seconds in a plain shell step, with `/dev/null` for standard input and no helper anywhere near it. So the fault is the command, not how `mixengine-elevate` runs it — the environment, the nesting and a password prompt on standard input are all ruled out, and what is left is the authorization path. Then the probe stopped, because this step's shell is `bash -e`: `perl` was killed, `-e` saw a non-zero status, and A2 through C3 went unasked. `bounded` promised never to fail the step and could not keep it; the call now sits in a condition context, which is what makes it exempt. And the run asks about a mechanism rather than only about variations of the broken one. `security trust-settings-export -d` and `trust-settings-import -d` read and write the admin domain as one property list, which is what a removal there actually is: drop our entry, keep everybody else's, put it back. B1 and B2 ask whether each direction works at all with no agent to display; B3 is the removal itself. D1 asks the last thing worth knowing about `remove-trusted-cert` — whether it hangs when there is nothing left to remove, which would make it a call that never could have answered.
… domain (T49c) The last run answered the mechanism question and produced one result this run exists to check. Everything that *writes* the admin trust domain without adding to it hangs, and hangs for reasons that are not about our data: `remove-trusted-cert -d` under plain sudo, under `sudo -H`, with HOME unset, against a root-owned path, and — the one that settles it — when there is nothing left to remove at all. `trust-settings-import -d` hangs the same way, both with the domain unchanged and with our entry dropped. `export` reads it fine and `add-trusted-cert -d` writes it fine. Without `-d` the removal fails in a millisecond, so it is the admin domain specifically. And `security delete-certificate` answered immediately, took the certificate out of the keychain, and left `dump-trust-settings -d` with nothing at all — which contradicts what T49c currently writes down as a dead end. That entry says a delete leaves the trust setting behind because macOS keys them by certificate hash; the measurement says otherwise, and the reason is that the admin domain *is* `/Library/Keychains/System.keychain` rather than a store beside it. **But it ran against a domain holding one certificate, where "deleted the right entry" and "wiped the domain" print the same thing.** Taking somebody's corporate root out along with ours would be the worst defect this task could ship, and one certificate could never have detected it. So: two certificates, delete one, and look at what happened to the other. It also asks what may identify the certificate being deleted. `-Z` takes the SHA-1 the system itself reports, which would let a removal name the exact certificate it means — the check `windows::store::remove` already performs against every certificate it walks, and the one that a match on common name would give up.
…things that had to be measured to know it (T49a) `security remove-trusted-cert -d` never returns. Not under plain `sudo`, not under `sudo -H`, not with `HOME` unset, not against a root-owned path, and — the case that says what kind of fault it is — **not even when there is nothing left to remove**. Without `-d` it fails in a millisecond, so it is the admin trust domain specifically. `trust-settings-import -d` hangs identically, with the domain unchanged and with one entry dropped, while `trust-settings-export -d` reads that domain and `add-trusted-cert -d` writes it. On a machine with no window server the admin trust domain can be **read and added to, and neither removed from nor replaced.** `security delete-certificate` answers at once, takes the certificate out of the keychain, and takes the trust setting with it — because the admin domain *is* `/Library/Keychains/System.keychain` rather than a store beside it. **That last sentence is the one this branch had already written down as a dead end**, in the opposite direction: trust settings are keyed by certificate hash, so a delete would leave the machine trusting a certificate it no longer holds, and an uninstall built on it would report a removal it had not performed. The reasoning was sound and the answer was wrong, which is the whole argument for a probe rather than a paragraph. **And one certificate could not have proved the delete was targeted.** A domain holding a single entry prints the same thing whether one entry was deleted or the domain was wiped, and taking somebody's corporate root out along with ours is the worst defect this task could ship. So: two certificates installed, one deleted, and the other was still present and still trusted. **What names the certificate is the SHA-1 `security` printed for it**, read out of the same `find-certificate -a -Z -p` listing the shape check runs against. `delete-certificate -c` would match on common name and give up the check every other system here performs — `windows::store::remove` runs it against each certificate it walks, and so does this. The DER stays the thing that is checked; the hash is only how the answer is spoken back, it is never computed here, and D11's refusal of a hashing dependency in the binary that runs as root stands untouched. It is validated as forty hexadecimal characters before it is passed, even having come from `security` a moment ago, because validating what it is about to act on rather than trusting where it came from is what this binary is. The reader now parses the listing block by block instead of through `pem::decode_all`, since what it needs is the *pairing* of a hash with its certificate, and `decode_all` is left with exactly one caller — the Linux bundle check — with a `cfg` that names it rather than a looser one that would be dead code on some build of some other system. The round trip in `mixengine-elevate/tests/system.rs` asks all three systems for the same four answers again, and the macOS carve-out and the T49c entry it pointed at are both gone.
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.
T49 is split at the privilege boundary — the only line its two halves differ on. The system stores
need root and ride in the first-run elevation batch; the NSS databases belong to the user and cannot
be batched into a prompt at all, there being no prompt to batch them into. This is the first half.
Design: T49a spec.
What it does
PrivilegedOp::TrustCaInstallandTrustCaRemove, aTrustStoretrait with three implementations,a producer in the daemon that asks on every start, a
mix doctorcondition, and atrustfield oncert.ca_statusthat T48 could only ever have answered "unknown".CertAddEncodedCertificateToStoreintoLocalMachine\RootCertDeleteCertificateFromStoresecurity add-trusted-cert -d -r trustRoot -kthe System keychainsecurity delete-certificate -Zsecurity find-certificate -a -Z -pThe removal carries no fingerprint, and that is the security argument
An install is close to harmless: a daemon compromised badly enough to forge one already holds the CA
key and can sign anything. A removal naming a certificate by hash is not — it could take the root
that validates Windows Update out of a machine, through the audited binary, under the user's own
Allow click. So the wire type has no fingerprint field. What travels is T48's eight-character
key-id, which cannot describe a corporate root, and the helper checks the whole shape again against
every certificate the store hands back before removing anything.
Two dependencies costed and refused, and costing one of them corrected the design
x509-parseris 29 crates with 7 already present;sha2is 8 with none — both into a binary thatruns as root and whose closure CI diffs. Costing
sha2found that the check it was for, recomputingthe key-id from the public key, refuses nothing: whoever generates a certificate sets its name to
their own key's identifier. So the checks are hand-written over a DER reader that only knows how to
say no, and the key-id earns its keep naming an authority for removal instead.
pemwas measured attwo crates and taken.
What only a real store could answer
CRYPT_E_EXISTS, notERROR_ALREADY_EXISTS. The cryptolayer's
HRESULTthroughSetLastError; the ranges do not overlap. Invisible on a first installand a failure on every one after it — which is why the round trip asks for the second answer as
well as the first.
security remove-trusted-cert -dnever returns on a machine with no window server. Not underplain
sudo, not undersudo -H, not withHOMEunset, not against a root-owned path, and noteven when there is nothing left to remove.
trust-settings-import -dhangs the same way, whileexportreads that domain andadd-trusted-certwrites it.security delete-certificateanswersat once and takes the trust setting out with the certificate, because the admin domain is
/Library/Keychains/System.keychainrather than a store beside it — the opposite of what thisbranch had reasoned and written down. Proved targeted rather than wholesale by installing two
certificates and deleting one: the other was still there and still trusted.
securitycall has/dev/nullfor standard input and a thirty-second deadline. The first hangcost twenty minutes of a job that printed nothing at all.
Failedthat names the action and drops the cause cannot be acted on. All four sites inmixengine-elevatenow carry the OS's own words throughmixengine_proto::flatten.Deliberately not done
No producer for the removal — built, validated and tested with none, on T42's D12 and T45's D13,
because T54 and T87 are the producers. No
certutilfallback on Windows. Nothing is recorded aboutwhether the machine trusts us: a stored flag is a claim an OS update or another account can falsify
silently, and reading costs no privilege on any of the three systems — which
mixengine-platform/tests/trust.rsmeasures in CI's ordinary job rather than asserting in a comment.Thirteen existing tests changed, all because a started daemon now queues what first-run setup needs
and they had written the queue's length, or the doctor's check count, as a constant. None was
filtered green.