Skip to content

feat(certs): this machine's trust store on three systems, and the four things only a real one could answer (T49a) - #32

Merged
haiquang9994 merged 18 commits into
masterfrom
feat/t49a-system-trust-store
Aug 25, 2026
Merged

feat(certs): this machine's trust store on three systems, and the four things only a real one could answer (T49a)#32
haiquang9994 merged 18 commits into
masterfrom
feat/t49a-system-trust-store

Conversation

@haiquang9994

Copy link
Copy Markdown
Collaborator

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::TrustCaInstall and TrustCaRemove, a TrustStore trait with three implementations,
a producer in the daemon that asks on every start, a mix doctor condition, and a trust field on
cert.ca_status that T48 could only ever have answered "unknown".

System Install Remove Read
Windows CertAddEncodedCertificateToStore into LocalMachine\Root CertDeleteCertificateFromStore enumerate, compare DER exactly
macOS security add-trusted-cert -d -r trustRoot -k the System keychain security delete-certificate -Z security find-certificate -a -Z -p
Linux the anchors directory this machine has, then its refresh command remove the anchor, refresh the anchor's bytes and the generated bundle

The 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-parser is 29 crates with 7 already present; sha2 is 8 with none — both into a binary that
runs as root and whose closure CI diffs. Costing sha2 found that the check it was for, recomputing
the 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. pem was measured at
two crates and taken.

What only a real store could answer

  • Windows reports "already there" as CRYPT_E_EXISTS, not ERROR_ALREADY_EXISTS. The crypto
    layer's HRESULT through SetLastError; the ranges do not overlap. Invisible on a first install
    and 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 -d never returns on a machine with no window server. Not under
    plain sudo, not under sudo -H, not with HOME unset, not against a root-owned path, and not
    even when there is nothing left to remove. trust-settings-import -d hangs the same way, while
    export reads that domain and add-trusted-cert writes it. security delete-certificate answers
    at once and takes the trust setting out with the certificate, because the admin domain is
    /Library/Keychains/System.keychain rather than a store beside it — the opposite of what this
    branch 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.
  • A helper behind an elevation prompt has no console and must never wait for one. Every
    security call has /dev/null for standard input and a thirty-second deadline. The first hang
    cost twenty minutes of a job that printed nothing at all.
  • A Failed that names the action and drops the cause cannot be acted on. All four sites in
    mixengine-elevate now carry the OS's own words through mixengine_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 certutil fallback on Windows. Nothing is recorded about
whether 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.rs measures 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.

…(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.
…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.
@haiquang9994
haiquang9994 merged commit 329fbad into master Aug 25, 2026
10 checks passed
@haiquang9994
haiquang9994 deleted the feat/t49a-system-trust-store branch August 25, 2026 00:53
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