Why every secret is a SecretString¶
Every public function in this crate that touches a secret takes or
returns secrecy::SecretString. Passing a &str or String where a
secret is expected is a compile error, and that is treated as a
correctness rule rather than a matter of style.
Storage decides where a secret lives before the process starts. This decides what happens to it afterwards, which is where credentials actually tend to leak.
The two leaks an ordinary string does not prevent¶
The accidental print. Somebody debug-formats a config struct, or an
error includes the struct that holds the key, or a panic dumps it. A
secret held as a String renders like any other string, straight into
whatever aggregates the logs. Nothing in the type system objects,
because nothing distinguishes that string from a filename.
The leftover bytes. The secret sat in a heap allocation. The value goes out of scope, the allocation is freed, and "freed" means returned to the allocator, not erased. The bytes stay until something writes over them. A core dump taken in that window contains the key.
Neither leak involves the storage decision being wrong. Both happen after a perfectly good credential has been retrieved.
What the type does about each¶
SecretString implements Debug itself, and it prints
SecretBox<str>([REDACTED]). Not "usually", and not "if the field is
annotated" — a struct holding one can be debug-printed, embedded in an
error, or caught in a panic, and the field renders redacted every time.
Nobody has to remember anything.
It also zeroes its memory on drop. When the value goes out of scope the bytes are overwritten before the allocation returns to the allocator, so the window in which a freed allocation still holds the key is closed.
The shift is from discipline to structure. Not leaking a secret stops being something every developer has to get right on every line and becomes something the value does to itself because of what it is.
Why the crate clones secrets instead of re-wrapping them¶
LiteralStore::get returns self.secret.clone() rather than going via
expose_secret().to_string(), and Resolver::resolve does the same
with a literal.
The difference matters. Cloning a SecretString produces another
zeroed-on-drop container, so the value never exists outside the
protection. Exposing it to build a fresh String would leave an
ordinary, un-zeroed String on the way through — one that is dropped
without being wiped, reintroducing precisely the leftover-bytes problem
the type exists to close.
It is a small thing that is easy to get backwards, which is why the code carries a comment at both sites.
Why serialisation is blocked in one direction only¶
SecretString can be deserialised but not serialised, and
CredentialRef inherits both halves of that.
Deserialising is necessary: the literal key has to be readable from a
config file or the source would not work at all.
Serialising is refused because of a specific, ordinary accident. A tool
loads its config, changes one unrelated setting, and writes the whole
struct back. With a plain string, that faithfully writes the resolved
secret to disk in plain text, and no one reviews it because no one wrote
the line that did it. Because SecretString has no Serialize impl,
CredentialRef cannot derive one either, and the accident becomes a
compile error.
The mechanism in secrecy is worth knowing, because "it was removed" is
the common and slightly wrong summary. SecretString is
SecretBox<str>, and secrecy does provide
impl Serialize for SecretBox<T> — but only where T implements the
opt-in SerializableSecret marker and is Sized. str is neither.
Serialisation is opt-in per secret type, and string secrets never opted
in.
The cost is real and lands on tools that legitimately write config back: credential fields have to be separated from the serialisable struct and handled deliberately. That is the intended trade — writing a secret to disk should be a decision somebody made, not a side effect of saving a file.
expose_secret is deliberately awkward¶
Code that genuinely needs the plaintext — to build an Authorization
header, say — calls expose_secret().
The name is doing work. It is explicit, it is greppable, and it reads badly in review when it appears somewhere it should not. One conspicuous call at the point of use is a much better arrangement than a secret that is an ordinary string everywhere and therefore unremarkable everywhere.
What the type cannot do¶
SecretString protects a value while it is inside a SecretString. It
has nothing to say about anything else.
After expose_secret() the returned &str is an ordinary string
slice, and nothing stops it being logged, formatted into a URL, or
copied into a String that is never zeroed. The guarantee ends at that
call.
It does not protect against a process being inspected while the secret
is live, memory being swapped to disk, or a third-party library
retaining a copy of a string it was handed. And it does nothing about
secrets that were never in a SecretString to begin with — a token
embedded in a URL, or one that arrived inside an error message from
another crate. Catching those is pattern-matching on output, a different
job handled elsewhere in the phpboyscout toolkit.