Skip to content

feat(types): introduce SecretString and use it for LoginParams password#56

Open
devin-ai-integration[bot] wants to merge 1 commit intomainfrom
devin/1776970457-secret-string
Open

feat(types): introduce SecretString and use it for LoginParams password#56
devin-ai-integration[bot] wants to merge 1 commit intomainfrom
devin/1776970457-secret-string

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot commented Apr 23, 2026

Summary

Passwords passed through LoginParams used to live in a plain String. String does not zeroise its buffer on drop, so compromised heap dumps or post-free memory reads could surface the cleartext password long after login finished. LoginParams also derived Debug, so ad-hoc println! / tracing of it leaked the full credential.

This PR introduces a minimal, crate-local zeroising secret wrapper and adopts it on LoginParams.

New API (teamtalk::types):

  • SecretString — UTF-8 secret with:
    • #[derive(Clone, ZeroizeOnDrop)] so the backing buffer is wiped when the value (or a clone) goes out of scope.
    • Custom Debug / Display that render as <redacted> so the secret never hits logs.
    • Content-based, branch-less PartialEq / Eq over equal-length byte strings (lengths still short-circuit).
    • expose_secret() -> &str as the single read-out method; doc block explicitly warns to keep the borrow narrow.
    • zeroize_in_place() to wipe the buffer while keeping capacity.
    • From<String>, From<&str>, From<&String>, From<Cow<'_, str>> so callers do not need to think about the wrapper unless they want to.
  • Re-exported as teamtalk::types::SecretString (and via teamtalk::types::secret::SecretString for the focused path).

LoginParams migration:

  • LoginParams.password: StringSecretString.
  • LoginParams::new(.., password: impl Into<SecretString>, ..) so callers keep passing &str/String unchanged.
  • LoginParams::from_env() continues to read TT_PASS into a SecretString.
  • Debug derive on LoginParams now auto-redacts the password; nickname and username remain visible.
  • Internal consumers updated to call params.password.expose_secret() at the FFI boundary only (client/users/auth.rs::login_with_params, login_from_env, client/core/recovery.rs auto-login path).

Dependency: adds zeroize = { version = "1.8", features = ["zeroize_derive"] } to teamtalk's Cargo.toml. The secrecy crate was intentionally not pulled in to keep the dependency surface small; the local wrapper has no external users.

Breaking change note: LoginParams.password: StringSecretString. LoginParams is already #[non_exhaustive], and SecretString implements From<String> / From<&str>, so constructing LoginParams via new() continues to work with the same argument types as before. Direct field reads now need .expose_secret() to recover &str.

Tests: crates/teamtalk/tests/secret_string_tests.rs adds 12 integration cases — expose_secret round-trip, default/new/is_empty/len, zeroize_in_place, Debug/Display redaction, content-based equality across different-length inputs, From<{String, &str, &String, Cow}>, Clone, LoginParams.password is a SecretString, LoginParams Debug does not leak the password but keeps nickname/username visible, LoginParams::new() accepts owned String/&str/pre-built SecretString, empty secret still renders <redacted>.

Local verification: cargo fmt --all --check clean; cargo clippy --workspace --all-targets --all-features -- -D warnings clean; cargo test --workspace --all-features — all existing tests pass plus 12 new SecretString cases.

Review & Testing Checklist for Human

  • Confirm the decision to scope this PR to LoginParams.password only — UserAccount.password (in types/entities/accounts.rs) is still a plain String. It is a stored admin data model rather than an in-flight client credential, so the trade-off is different; a follow-up PR can lift SecretString there too if desired.
  • Confirm the decision to roll a crate-local SecretString instead of adopting the secrecy crate. Rationale: zero new transitive deps, no ExposeSecret trait surface to document, and secrecy 0.10 is still in the Rust ecosystem churn.
  • Confirm that the in-repo callers of LoginParams.password that now go through expose_secret() keep the borrow narrow (client/users/auth.rs and client/core/recovery.rs). The test login_params_debug_does_not_leak_password pins the Debug behaviour; please still skim auth.rs / recovery.rs and confirm you do not want additional redaction hooks (e.g. in tracing events).

Notes

Fifth P1 API improvement after ExponentialBackoff jitter (#51), StreamTypes (#52), SdkErrorCode (#54), and TimeoutKind (#55). Branches from clean main, independent of the other open PRs, so can be merged in any order.

Next up: indexed EventBus::dispatch / Router::dispatch via HashMap (keyed on Event discriminant for O(subscribers-on-event) dispatch), then a coverage-gap test fill-in driven by scripts/audit_teamtalk_coverage.py.

The pre-existing semver CI gate is expected to remain red; release-plz handles the eventual version bump.

Link to Devin session: https://app.devin.ai/sessions/71fdd6196cb74723a2e277bb81993a9c
Requested by: @BlindMaster24


Open in Devin Review

Passwords passed through LoginParams used to live in a plain
String. Rust's String does not zeroise its buffer on drop, so
compromised heap dumps or post-free memory reads could surface
the cleartext password long after login finished. LoginParams
also derived Debug, so ad-hoc println!/tracing of it leaked the
full credential.

This PR introduces a minimal, crate-local zeroising secret
wrapper and adopts it on LoginParams.

New API (teamtalk::types):

* SecretString — UTF-8 secret with:
  - #[derive(Clone, ZeroizeOnDrop)] so the backing buffer is
    wiped when the value (or a clone) goes out of scope.
  - Custom Debug / Display that render as '<redacted>' so the
    secret never hits logs.
  - Content-based, branch-less PartialEq / Eq over equal-length
    byte strings (a.len() != b.len() still short-circuits).
  - expose_secret() -> &str as the single read-out method; doc
    block explicitly warns to keep the borrow narrow.
  - zeroize_in_place() to wipe the buffer while keeping capacity.
  - From<String>, From<&str>, From<&String>, From<Cow<'_, str>>
    so callers do not need to think about the wrapper unless
    they want to.
* Re-exported as teamtalk::types::SecretString (and via
  teamtalk::types::secret::SecretString for the focused path).

LoginParams migration:

* LoginParams.password: String -> SecretString.
* LoginParams::new(.., password: impl Into<SecretString>, ..) so
  callers keep passing &str/String unchanged.
* LoginParams::from_env() continues to read TT_PASS into a
  SecretString.
* Debug derive on LoginParams now auto-redacts the password
  because SecretString's Debug returns '<redacted>'; nickname
  and username remain visible.
* All internal consumers updated to call
  params.password.expose_secret() at the FFI boundary only:
  - client/users/auth.rs: login_with_params, login_from_env.
  - client/core/recovery.rs: auto-login path after reconnect.

Dependency: adds zeroize = { version = "1.8", features =
["zeroize_derive"] } to teamtalk's Cargo.toml. The secrecy
crate was intentionally not pulled in to keep the dependency
surface small; the local wrapper has no external users.

Breaking change note:
  LoginParams.password: String -> SecretString.
  LoginParams is #[non_exhaustive] so field-init syntax already
  required .. rest patterns, but direct field reads now need
  .expose_secret() to get &str. SecretString implements
  From<String>/From<&str>, so constructing LoginParams with new()
  continues to work with the same argument types as before.

Tests: crates/teamtalk/tests/secret_string_tests.rs adds 12
integration cases covering:

* expose_secret round-trip, default / new / is_empty / len.
* zeroize_in_place empties the buffer.
* Debug and Display do not contain the plaintext and do mention
  'redacted'.
* Content-based equality and inequality including
  different-length secrets.
* From<String>, From<&str>, From<Cow<'_, str>>, From<&String>.
* Clone preserves the value.
* LoginParams.password is a SecretString and is accessed via
  expose_secret().
* LoginParams Debug does not leak the password but keeps nickname
  and username visible.
* LoginParams::new() still accepts owned String, &str, and a
  pre-built SecretString for the password argument.
* Empty secret still renders redacted in Debug.

Local verification:

* cargo fmt --all clean.
* cargo clippy --workspace --all-targets --all-features -- -D
  warnings clean.
* cargo test --workspace --all-features: all existing tests pass
  plus 12 new SecretString cases.
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Copy link
Copy Markdown
Contributor Author

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 4 additional findings.

Open in Devin Review

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