fix(mitos): zeroize AuthInfo::auth_key on drop - #93
Merged
Conversation
Pre-auth keys are reusable, unattended-enrollment credentials, and a plain Option<String> leaves the heap allocation holding one behind after use -- recoverable from a core dump, /proc/<pid>/mem, or swap. Wrap the field in zeroize::Zeroizing<String> (serde feature enabled so the wire format is unchanged) and route every construction through AuthInfo::new, which puts the key text into exactly one allocation, wrapped immediately, so no separate unwrapped copy is left at the call site. The field goes pub(crate): a direct auth_key: None literal would also produce a wire shape (an empty Auth object) no real caller wants.
Cargo.toml enabled zeroize's serde feature but Cargo.lock still listed zeroize with no serde dependency edge, so a --locked build would have diverged from what cargo actually resolves.
Gate-Passed: kanon 0.12.0 +stages:fmt,check,clippy,nextest,lint sha:e5194f8914a2930f194fbfbabfe068849c8153b8
AuthInfo::auth_key was wrapped in Zeroizing<String>, but the crate's own shipped reference implementation (examples/connect.rs) read TS_AUTHKEY into a plain Option<String> that outlived registration for the rest of the process, including the unbounded map-stream loop. That raw allocation was never scrubbed -- the fix protected a copy, not the secret's origin. Wrap the env-var read in Zeroizing in the same statement that allocates it (mirroring AuthInfo::new: one allocation, wrapped before any other binding can hold an unwrapped copy), and drop it explicitly once registration no longer needs it rather than letting it live to the end of run().
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.
Finding
AuthInfo::auth_keystored the Tailscale pre-auth secret as a plainOption<String>.Stringhas noZeroize/ZeroizeOnDrop, so the heap allocation backing it survived past the value's drop — recoverable from a core dump,/proc/<pid>/mem, or swap.Note: the issue's file paths (
hamma-core,dictyon/src/control/keys.rs) predate a repo restructure —hamma-coreis nowmitos, and the key types live atcrates/mitos/src/keys.rs. Paths below are current.Evidence
crates/mitos/src/types/mod.rs:86— field is nowpub(crate) auth_key: Option<Zeroizing<String>>(waspub auth_key: Option<String>).crates/mitos/src/types/mod.rs:97-99— the only public constructor,AuthInfo::new, wraps the raw&strinZeroizing::new(auth_key.to_string())in the same statement that allocates it: one allocation, wrapped before any other binding can hold an unwrapped copy.crates/dictyon/src/control/mod.rs:302— the sole real construction site now readsauth_key.map(AuthInfo::new), replacing the oldAuthInfo { auth_key: Some(k.to_string()) }literal that put a bare, un-zeroizedStringinto the field.Cargo.toml:66—zeroizegains theserdefeature soZeroizing<String>keeps exactly theSerializeoutputStringhad.crates/mitos/src/types/tests.rs::scrubbing_the_pre_auth_key_zeroes_its_backing_bytes(new test) — exhibits the scrub at the byte level rather than trusting the wrapper type.String::zeroizeoverwrites the buffer and then truncates the length to 0 (zeroize-1.8.2src/lib.rs, theVec<Z>impl), so a safe accessor (as_bytes/as_str) reads an empty string on both a genuine scrub and a no-op — it can't tell the difference. The test captures the pointer and length before scrubbing, callsZeroize::zeroize(the exact callZeroizing'sDropmakes, exercised without dropping the wrapper so the allocation stays live to inspect), and reads those samelenbytes back to assert every one is0. This is the oneunsafeblock in the crate; it is#[expect(unsafe_code, reason = "...")], not#[allow], per this repo's standard.Authis present:crates/mitos/src/types/tests.rs:87—register_request_serializes_to_jsonassertsjson.contains("\"AuthKey\":\"tskey-auth-test\"").crates/mitos/tests/public_api.rs:108—register_request_omits_none_fieldsasserts the same.crates/dictyon/src/control/tests.rs:400-401(pre-existing, untouched) —register_builds_correct_jsonassertsauth["AuthKey"].as_str()equals the literal input"tskey-auth-test123"end-to-end throughbuild_register_request.Why this matters
A pre-auth key authorizes unattended device enrollment and is reusable — unlike a node key, it isn't scoped to one session, so a leaked copy lets an attacker enroll additional unauthorized nodes. Wrapping the field in a zeroizing type is only a real fix if the value is actually scrubbed at the byte level, no parallel unwrapped copy exists at the one place the field gets populated, and the raw secret is never left unwrapped anywhere upstream of that point either — all three are exhibited above, not assumed.
Desired correction
Applied. Sibling-secret audit (per the issue's ask to check for the same defect elsewhere): every other
String/&strfield in this workspace that carries material shaped like a secret is either (a) a private key, alreadyZeroizeOnDropvia a fixed[u8; 32]newtype incrates/mitos/src/keys.rs, or (b) a public key hex string, each individually marked// kanon:ignore RUST/plain-string-secret -- ... not a secretat its declaration (thenode_key/old_node_key/disco_key/keyfields incrates/mitos/src/types/mod.rs).auth_keywas the only unmarked, undefended secret-shaped field in the workspace.Closes #50
Addressing adversarial review (2 findings, both fixed)
Finding 1 — the original allocation was never scrubbed, only a copy.
AuthInfo::newcorrectly wraps its input inZeroizing, but every real call path traced back tocrates/dictyon/examples/connect.rs:92, the crate's own shipped reference implementation, wherestd::env::var("TS_AUTHKEY")landed in a plainOption<String>that lived onrun()'s stack for the whole process — through registration and intostream_map's unboundedloop.AuthInfo::new'sZeroizingwrapper only ever protects the copy it makes on the way in; it cannot reach back and scrub a caller's own allocation, because the public API takes a borrowedOption<&str>. That left the actual root secret unprotected in the one place this repo shows anyone how to use it — the identical memory-disclosure vector the fix exists to close.Fixed:
crates/dictyon/examples/connect.rs:99now wraps the env-var read inZeroizing::new(value)in the same statement that allocates it (mirroringAuthInfo::new's own pattern — one allocation, wrapped before any other binding can hold an unwrapped copy), andcrates/dictyon/examples/connect.rs:142drops it explicitly the moment registration no longer needs it, rather than letting it live to the end ofrunthrough the indefinite map-stream loop.zeroizeis now adictyondev-dependency (crates/dictyon/Cargo.toml) for exactly this use.Re-checked every other construction path for the same shape (raw allocation kept alongside/upstream of a zeroizing wrapper):
crates/dictyon/src/control/mod.rs:302(auth_key.map(AuthInfo::new)) takes a borrowed&strand wraps insideAuthInfo::newwith no separate owned copy at the call site;crates/dictyon/tests/wire_integration.rs:393constructs a literal test fixture string, not a real secret flow.examples/connect.rswas the only production/example code path with the defect.No check added for this half of the fix, stated explicitly rather than left implicit:
examples/connect.rsis afn mainbinary that dials the real Tailscale control plane, with no unit-test harness, and there is no clippy lint (fleet or upstream) that flags an unwrappedStringholding secret-shaped data. Verification here is the diff itself plus the pre-existing type-level tests continuing to hold (scrubbing_the_pre_auth_key_zeroes_its_backing_bytes, the twoauth_info_debug_*redaction tests) — nothing new to regress-test at the example-code layer.Finding 2 — PR body cited the wrong tests as wire-format evidence.
register_request_omits_none_fieldsandregister_request_serializes_to_jsonwere cited as asserting on the"AuthKey"wire value; neither did — both only checkedjson.contains("\"Auth\""), which passes even for a missing, empty, or wrong-shaped nestedAuthKey.Fixed by closing the actual gap rather than just re-pointing the citation: both tests now assert the exact
"AuthKey":"tskey-auth-test"substring (crates/mitos/src/types/tests.rs:87,crates/mitos/tests/public_api.rs:108), so the original claim is true of the tests it names.crates/dictyon/src/control/tests.rs::register_builds_correct_json(pre-existing) is cited above too, as the end-to-end path throughbuild_register_request.Negative fixture:
crates/mitos/src/types/mod.rs(AuthInfo::new) — watched failing bycargo test -p mitos --lib types::tests::register_request_serializes_to_jsonandcargo test -p mitos --test public_api register_request_omits_none_fields, run against a deliberately brokenAuthInfo::new(Zeroizing::new(auth_key.to_string())changed toZeroizing::new(String::new()), discarding the key). Both failed:The old
.contains("\"Auth\"")assertion would have passed against that same broken output ("Auth":{"AuthKey":""}still contains"Auth"). RestoredAuthInfo::new, reran both — both pass.