Skip to content

fix(mitos): zeroize AuthInfo::auth_key on drop - #93

Merged
forkwright merged 6 commits into
mainfrom
fix/50-zeroize-auth-key
Aug 16, 2026
Merged

fix(mitos): zeroize AuthInfo::auth_key on drop#93
forkwright merged 6 commits into
mainfrom
fix/50-zeroize-auth-key

Conversation

@forkwright

@forkwright forkwright commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Finding

AuthInfo::auth_key stored the Tailscale pre-auth secret as a plain Option<String>. String has no Zeroize/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-core is now mitos, and the key types live at crates/mitos/src/keys.rs. Paths below are current.

Evidence

  • crates/mitos/src/types/mod.rs:86 — field is now pub(crate) auth_key: Option<Zeroizing<String>> (was pub auth_key: Option<String>).
  • crates/mitos/src/types/mod.rs:97-99 — the only public constructor, AuthInfo::new, wraps the raw &str in Zeroizing::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 reads auth_key.map(AuthInfo::new), replacing the old AuthInfo { auth_key: Some(k.to_string()) } literal that put a bare, un-zeroized String into the field.
  • Cargo.toml:66zeroize gains the serde feature so Zeroizing<String> keeps exactly the Serialize output String had.
  • 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::zeroize overwrites the buffer and then truncates the length to 0 (zeroize-1.8.2 src/lib.rs, the Vec<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, calls Zeroize::zeroize (the exact call Zeroizing's Drop makes, exercised without dropping the wrapper so the allocation stays live to inspect), and reads those same len bytes back to assert every one is 0. This is the one unsafe block in the crate; it is #[expect(unsafe_code, reason = "...")], not #[allow], per this repo's standard.
  • Wire-format preservation, exhibited (not asserted) — both tests now pin the exact nested value, not just that Auth is present:
    • crates/mitos/src/types/tests.rs:87register_request_serializes_to_json asserts json.contains("\"AuthKey\":\"tskey-auth-test\"").
    • crates/mitos/tests/public_api.rs:108register_request_omits_none_fields asserts the same.
    • crates/dictyon/src/control/tests.rs:400-401 (pre-existing, untouched) — register_builds_correct_json asserts auth["AuthKey"].as_str() equals the literal input "tskey-auth-test123" end-to-end through build_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/&str field in this workspace that carries material shaped like a secret is either (a) a private key, already ZeroizeOnDrop via a fixed [u8; 32] newtype in crates/mitos/src/keys.rs, or (b) a public key hex string, each individually marked // kanon:ignore RUST/plain-string-secret -- ... not a secret at its declaration (the node_key/old_node_key/disco_key/key fields in crates/mitos/src/types/mod.rs). auth_key was 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::new correctly wraps its input in Zeroizing, but every real call path traced back to crates/dictyon/examples/connect.rs:92, the crate's own shipped reference implementation, where std::env::var("TS_AUTHKEY") landed in a plain Option<String> that lived on run()'s stack for the whole process — through registration and into stream_map's unbounded loop. AuthInfo::new's Zeroizing wrapper 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 borrowed Option<&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:99 now wraps the env-var read in Zeroizing::new(value) in the same statement that allocates it (mirroring AuthInfo::new's own pattern — one allocation, wrapped before any other binding can hold an unwrapped copy), and crates/dictyon/examples/connect.rs:142 drops it explicitly the moment registration no longer needs it, rather than letting it live to the end of run through the indefinite map-stream loop. zeroize is now a dictyon dev-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 &str and wraps inside AuthInfo::new with no separate owned copy at the call site; crates/dictyon/tests/wire_integration.rs:393 constructs a literal test fixture string, not a real secret flow. examples/connect.rs was 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.rs is a fn main binary that dials the real Tailscale control plane, with no unit-test harness, and there is no clippy lint (fleet or upstream) that flags an unwrapped String holding 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 two auth_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_fields and register_request_serializes_to_json were cited as asserting on the "AuthKey" wire value; neither did — both only checked json.contains("\"Auth\""), which passes even for a missing, empty, or wrong-shaped nested AuthKey.

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 through build_register_request.

Negative fixture: crates/mitos/src/types/mod.rs (AuthInfo::new) — watched failing by cargo test -p mitos --lib types::tests::register_request_serializes_to_json and cargo test -p mitos --test public_api register_request_omits_none_fields, run against a deliberately broken AuthInfo::new (Zeroizing::new(auth_key.to_string()) changed to Zeroizing::new(String::new()), discarding the key). Both failed:

thread 'types::tests::register_request_serializes_to_json' panicked at crates/mitos/src/types/tests.rs:86:5:
AuthKey value wrong: {"NodeKey":"nodekey:abc123","OldNodeKey":"","Auth":{"AuthKey":""},"Hostinfo":{"BackendLogID":"log123","OS":"linux","Hostname":"testhost","GoVersion":"dictyon/0.1.0"}}

thread 'register_request_omits_none_fields' panicked at crates/mitos/tests/public_api.rs:107:5:
AuthKey value should be present under Auth: {"NodeKey":"nodekey:abc","OldNodeKey":"","Auth":{"AuthKey":""},"Hostinfo":{"BackendLogID":"log","OS":"linux","Hostname":"h","GoVersion":"dictyon/0.1.0"}}

The old .contains("\"Auth\"") assertion would have passed against that same broken output ("Auth":{"AuthKey":""} still contains "Auth"). Restored AuthInfo::new, reran both — both pass.

forkwright and others added 6 commits August 15, 2026 20:33
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().
@forkwright
forkwright merged commit a39bca4 into main Aug 16, 2026
10 checks passed
@forkwright
forkwright deleted the fix/50-zeroize-auth-key branch August 16, 2026 03:03
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.

Pre-auth key (AuthInfo::auth_key) is a plain String with no zeroization — secret persists on heap after use

1 participant