A credential the compiler refuses to print¶
Status: accepted. Built. sutura_domain::identity::Secret is a newtype over
secrecy::SecretString; four compile_fail doctests with compiling twins pin what stopped building,
each verified non-vacuous by unmarking it and reading the compiler's reason. The dependency cost is
+2 packages in the shipped binary's graph and +1 in Cargo.lock - measured, below.
What was wrong, and it was not a leak¶
Secret was Secret(String) with a hand-written Debug and a hand-written Display, both
printing a placeholder. Nothing ever leaked through either one, and there is no incident behind this
record.
The defect is that the accident stayed representable. Both of these compiled:
```rust,ignore tracing::info!(token = %token, "a caller presented a token"); let line = format!("token={token}");
Each produced a line reading `REDACTED`, at a call site whose author believed a value had been
logged. That is a redaction - a *check* - and this repository's own rule is *prefer unrepresentable to
checked*: a check can be moved, skipped or ordered wrongly, and a shape that cannot hold the value
cannot. A `Display` on a credential is a shape that holds it.
Two further gaps sat beside it. There was **no zeroization**: the value stayed in the process image
after the type dropped, for as long as the allocator left the page alone. And in
`sutura-exec-bigquery`'s credential wire, `Secret` was applied only *after* parsing - so one layer up,
two private documents derived `Debug` while holding a private key, a refresh token, a client secret
and an access token as raw `Option<String>`.
## Decision
**`sutura_domain::identity::Secret` is a newtype over `secrecy::SecretString`, and the `Display` is
gone.**
```rust,ignore
pub struct Secret(SecretString);
secrecy::SecretBox<str> has no Display, no PartialEq, and - with its serde feature off - no
Deserialize. So four things that used to compile no longer do, and each is pinned by a
compile_fail doctest with a compiling twin beside it that differs by one token:
| Accident | Compiler's reason, from unmarking the doctest |
|---|---|
format!("token={token}") |
E0277: Secret doesn't implement std::fmt::Display - "cannot be formatted with the default formatter" |
a Display bound, which is what % desugars to |
E0277: Secret doesn't implement std::fmt::Display, at the impl Display parameter |
a == b |
E0369: binary operation == cannot be applied to type Secret |
serde_json::from_str::<Secret>(..) |
E0277: the trait bound Secret: serde::Deserialize<'de> is not satisfied |
The real macro is pinned separately, in sutura_http::inbound - the one module holding caller-supplied
token material and a tracing dependency. tracing::info!(token = %token, ..) fails with
E0277: Secret doesn't implement std::fmt::Display, pointing at tracing_core::field::display. It
cannot live in the domain: cargo xtask check-boundaries walks sutura-domain's whole resolve graph,
which includes dev-dependencies, so a tracing dev-dependency there would fail the gate. The domain
therefore pins the bound and says so; the transport pins the macro.
Why a newtype and not the library type directly¶
Three properties, and the first is the one that matters:
- A
Deserializecannot arrive by feature unification.secrecy'sserdefeature givesSecretBoxaDeserialize, cargo unions features across a build graph, and a crate added later could turn it on for its own reasons. A newtype that derives nothing is unaffected. Sodefault-features = falsein the manifest is a supply-chain decision and not the mechanism - which matters, because the "a caller cannot state its own identity" invariant rests on the absence of that impl. SecretStringhas aDefault. That is the newtype guide's own counterexample pointed at credentials: a default secret is not a thing, and it would be a value that never passed a constructor.SecretStringhasFrom<String>andFrom<&str>- two ways in that bypassSecret::new. The wrapper has one canonical constructor because it does not restate them.
Secret::expose is renamed Secret::expose_secret, after secrecy's own trait method, so the
vocabulary is one word rather than two. It is an inherent method and not an ExposeSecret impl: a
trait would have to be in scope at every call site, and implementing it would make Secret
substitutable for the library type in generic code, which is the opposite of what the newtype is for.
0008 refers to the old name in two places;
those sentences are left as written, because an accepted record is a record of what was decided then.
The two BigQuery wire documents¶
wire::credential's private Document and TokenResponse lose their Debug derives. Their
fields stay Option<String>: Secret has no Deserialize, deliberately, so deserialize-then-wrap is
the only shape available and these types are the layer where the value is still bare.
Nothing printed them - serde_json's errors carry a line and a column rather than the input, and the
one {document:?} in that crate's tests is an unrelated &str fixture. The mechanism here is
weaker than the one above, and it is worth being plain about that: these are private types, so there
is no compile_fail doctest to write and no gate that fails. What holds is that the derive is absent
and that adding it back is a line in a diff. Review catches it or nothing does.
What this costs, measured¶
One new entry in Cargo.lock: 446 packages to 447, and it is secrecy. zeroize 1.9.0 was
already resolved - rustls and rustls-pki-types both depend on it - at the same version, so this
introduces no duplicate; zeroize_derive 1.5.0 was already in the lock beside it. Checked by grepping
the base lockfile for all three names rather than by reading the diff, because a lockfile diff of a
compacted file is not readable evidence.
Two in the shipped binary's graph: 231 packages to 233. sutura-cli links the engine and no TLS
stack, so it had neither. That is the honest number for a release artifact, and it is the one to quote.
cargo tree -p sutura-domain --all-features -e normal:
secrecy pulls only zeroize, and pulls it with default-features = false, features = ["alloc"],
so nothing follows behind it. Neither is a framework: no runtime, no client, no engine, no proc macro.
Both are Apache-2.0 OR MIT, which deny.toml already allows - so the licence allowlist needs no new
entry and no existing allowance becomes unused, which unused-allowed-license = "deny" would have
failed on. Checked with cargo deny, not assumed.
Two costs are real and are not on that list:
zeroizecontainsunsafe- volatile writes and a compiler fence. That is precisely why it is a dependency rather than sixty local lines:unsafe_codeisforbidacross this workspace, the guarantee is that the writes are not optimised away, and a test cannot observe whether they were. The same argument already carriessha2andsubtleinto this tree.secrecyitself isforbid(unsafe_code).secrecy 0.10.3was published 2024-10-09 and is the newest release; it has not moved in nearly two years. For a ~330-line crate whose whole content is three trait impls and aDrop, "finished" is a reasonable reading of that - but it is a maintenance bet, and the newtype is what makes it a cheap one to unwind:Secret's public surface isnew,expose_secret,DebugandClone, so replacing the inner type touches one file.
sutura-domain's dependency list is now five crates rather than four, and ALLOWED_IN_DOMAIN in
xtask/src/boundaries.rs names three new packages with the reason. That list exists so this is an
argument in a diff, and it worked as intended: adding secrecy and zeroize alone failed
check-boundaries by name -
- because that gate walks the whole-workspace resolve graph rather than deciding which optional edges
a feature resolver would really enable.
zeroize'sderivefeature is off, nothing in this workspace turns it on, andcargo tree -p sutura-domain --all-featuresdoes not listzeroize_derive; it is an entry of the over-broad kind that list already documents, and its own tree -proc-macro2,quote,syn- is the serde derives' tree, already allowed. The domain's walked tree is 31 crates, all allowlisted.
What was considered instead¶
Delete only the Display from the hand-written Secret, and add no dependency. This gets the
first two rows of the table above - {} and % both stop compiling - for zero new packages, and it
was put to the user as the cheaper option.
It was not chosen, and the decision is settled rather than open. It buys nothing for == beyond
what was already there, buys nothing at all for zeroization, and leaves the redaction a hand-written
formatter that a future diff can edit. The point of the change is that no first-party code formats a
credential; keeping our own Debug keeps a function whose job is to be careful. Against a measured +1
lockfile entry, that is not a trade worth taking.
Take zeroize alone and keep the hand-written type. Same package count as secrecy, and it
delivers the wipe. Rejected because it delivers only the wipe: the Display removal would still be
ours to remember, and secrecy is the vocabulary a reader already knows.
A Secret<T> generic over the material. Everything credential-shaped here is UTF-8 text.
SecretSlice<u8> is available under the same dependency on the day something holds key bytes, and a
generic invented before its second case would be a shape nobody could check.
What is claimed, and what is not¶
Claimed. No first-party code can render a Secret: there is no Display to call, the Debug is
secrecy's and prints SecretBox<str>([REDACTED]), and == does not compile. Neither impl can be
reintroduced by a derive on the wrapper, because the inner type has neither. The value is wiped on
drop.
Not claimed.
- The wipe covers the buffer this type holds, and no copy made before the value reached it. A
settings file read into a
String, aserde-deserializedOption<String>on a wire document, and theStringSecret::newconsumes are ordinary allocations - andString::into_boxed_strreallocates whenever capacity exceeds length, freeing the original buffer unwiped. Shortening that window means parsing intoSecretcloser to where bytes are read, not a stronger claim here. - There is no test for the wipe. Observing it means reading memory after free, which is undefined
behaviour. What is tested is that the type composes; the property is
zeroize's. {secret:?}still compiles, and is safe only because thatDebugcannot render the value. A call site can still writetoken.expose_secret()into a log, which is why that method is named to be conspicuous in a grep rather than relied upon to be absent.- The BigQuery
Debugremovals are held by review, not by a mechanism. Said again here because the rest of this record is about mechanisms. - Nothing about
Serialize.Secrethas none and never had one, and no doctest pins that separately - theDeserializeone is the half a request could reach. - Only two of the five
compile_faildoctests are red against the base behaviour, and this file says which rather than letting five look like five. The{}one and the twoDisplay-bound ones - the domain's and the realtracingmacro insutura_http::inbound- all PASS on base, which is a failure for acompile_failtest, because the old type had aDisplay; that was demonstrated by restoring the impl and watching them fail. The==andDeserializeones would have passed before this change too: both impls were already absent. They are in anyway, as regression pins on two invariant rows whose mechanism moved - from we did not write the impl to the inner type has none, so a derive on the wrapper cannot produce one. A row being restated is the moment to pin it; calling either one red-before-green would not be true.
Amendment, 2026-09-02: the expose_secret limit is now a lint¶
The bullet above - "a call site can still write token.expose_secret() into a log, which is why
that method is named to be conspicuous in a grep rather than relied upon to be absent" - is the one
limit in this record that a mechanism could close, and #145 closed it. clippy.toml disallows
sutura_domain::identity::Secret::expose_secret, so an exposure is an error under -D warnings
until somebody writes an #[expect] beside it saying what the exposed value is for. That is the
Warehouse::verify_anchor and tokio::task::spawn_blocking shape, and it was verified to resolve
the way this repository's clippy.toml header demands: the entry was added and clippy rejected the
existing call sites by file and line - twenty-one diagnostics across eleven files, answered by
eighteen expectations, because #[expect] is per item and three functions expose twice.
What changed is the DEFAULT, not the possibility. The exposures that were legitimate are still
there and still legitimate - a constant-time compare of the deployment token, the bearer header a job
is submitted with, the RFC 8693 subject token, a PKCS#8 key handed to ring, an assertion handed to
a broker, and the tests that read a minted credential in order to say whose it was. None of them
reaches a log. What is new is that a twenty-second one cannot arrive without a diff a reviewer sees.
Three limits, and the first is the one to read. A lint is not a type: it reaches this workspace,
an #[allow] walks past it, and clippy does not lint doctests at all - the three doctests in
sutura_domain::identity that call the method are outside it. #[expect] is per ITEM rather than per
call, so a function carrying one expectation may make two exposures; the reason text is what a
reviewer reads, and #[expect] at least fails when the last one goes away. And the ban says nothing
about what the exposed &str is then used for - that is still review, which is why every reason
here names the destination.