schwab_sdk/secrets.rs
1//! Domain types for sensitive strings.
2//!
3//! This module holds newtypes that flow across the public API. Sensitive
4//! string values (bearer tokens, customer identifiers, account numbers) are
5//! defined via the `sensitive_string_newtype!` macro, which produces a
6//! `SecretBox`-backed newtype with:
7//!
8//! - `Clone` (via `CloneableSecret`).
9//! - `Debug` that redacts via `secrecy`.
10//! - `Serialize` / `Deserialize` over the inner string (gated by
11//! `SerializableSecret`).
12//! - `new(impl Into<String>)` and `expose_secret() -> &str`.
13//! - `From<&str>`, `From<String>`, and `From<SecretString>` for convenience.
14//!
15//! # Example
16//!
17//! Construct an [`AuthToken`] for [`SchwabClient::new`](crate::SchwabClient::new),
18//! then reach the raw value only at the point of use:
19//!
20//! ```no_run
21//! use schwab_sdk::{AuthToken, SchwabClient};
22//!
23//! # async fn run() -> schwab_sdk::Result<()> {
24//! // Construction: the raw string is wrapped immediately. Prefer reading from
25//! // a credential store over `std::env::var` in production; see
26//! // "Token storage" below.
27//! let token = AuthToken::new(std::env::var("SCHWAB_AUTH_TOKEN").unwrap());
28//!
29//! // `Debug` redacts; the bearer never appears in `{:?}` output.
30//! println!("token = {token:?}"); // prints `token = AuthToken([REDACTED])`
31//!
32//! // The SDK reveals the secret internally only at the `Authorization`
33//! // header and the streamer LOGIN frame. Callers do not need to.
34//! let client = SchwabClient::new(token);
35//! let accounts = client.accounts().numbers().await?;
36//! # let _ = accounts;
37//! # Ok(())
38//! # }
39//! ```
40//!
41//! When a caller does need the raw value (e.g. when implementing a
42//! [`TokenProvider`](crate::TokenProvider) over an external store),
43//! [`expose_secret`](secrecy::ExposeSecret::expose_secret) can be used to
44//! retrieve it.
45//!
46//! ```
47//! use schwab_sdk::AuthToken;
48//!
49//! let token = AuthToken::new("abc123");
50//! assert_eq!(token.expose_secret(), "abc123");
51//! ```
52//!
53//! # Threat model
54//!
55//! These newtypes reduce the chance of accidental credential or PII
56//! leakage from code that uses them as intended. They are not a
57//! security boundary on their own; an explicit
58//! `.expose_secret().to_string()`, a misconfigured logger, or a
59//! compromised process defeats them.
60//!
61//! **What they help with**
62//!
63//! - `{:?}` / `dbg!` / `Debug`-derived `Error` variants do not print
64//! the secret. The redacted form is `Secret([REDACTED ...])`.
65//! - `Drop` zeroises the heap buffer that held the secret, narrowing
66//! the window during which a swap-out, post-free read, or stale-page
67//! capture could observe it.
68//! - `Clone` copies the protected box rather than producing a plain
69//! `String`, so the secret does not silently widen when passed
70//! around.
71//! - [`expose_secret`](secrecy::ExposeSecret::expose_secret) is the
72//! single, grep-able boundary that yields the raw value. Code review
73//! can enumerate every call site.
74//!
75//! **What they do not help with**
76//!
77//! - An explicit `.expose_secret().to_string()`, an assignment into a
78//! plain `String` field, or any other code path that copies the raw
79//! bytes out of the protected box. The `secrecy` machinery no longer
80//! applies to the copy.
81//! - A `Debug` impl elsewhere that captures an already-exposed form of
82//! the secret (e.g. a `serde_json::Value` built from
83//! `expose_secret()` and then `Debug`-printed).
84//! - A debugger, `ptrace` reader, or memory profiler attached to the
85//! live process.
86//! - A core dump that snapshots heap pages before `Drop` runs, or heap
87//! pages swapped to disk before the buffer was zeroised.
88//! - Logging frameworks, panic hooks, or backtrace machinery that
89//! capture values before this crate's redaction applies.
90//!
91//! These limits are listed so callers can make informed decisions about
92//! what additional process- and host-level hardening to apply. The
93//! crate is provided under MIT / Apache-2.0 with no warranty; see
94//! `SECURITY.md`.
95//!
96//! # Caller responsibilities
97//!
98//! The newtypes cover what happens to a secret once it is inside the
99//! SDK. Everything outside of that boundary (where the secret comes from,
100//! how it is logged, what other process-level state can see it) is the
101//! caller's responsibility.
102//!
103//! Below are recommendations for how to handle the secrets in your own code.
104//!
105//! ## Token storage
106//! The SDK does not persist tokens. Put the refresh token in an OS-native
107//! credential store (Keychain on macOS, Credential Manager on Windows,
108//! `keyring`/`keyring-core` against kernel keyutils on Linux). Do not commit
109//! tokens to `.env`, config files, or CI environment variables visible across
110//! jobs. A refresh token carries trading authority on a real-money account;
111//! treat it at that sensitivity.
112//!
113//! The [`keyring-core`](https://crates.io/crates/keyring-core) and its
114//! platform-native implementations are a good starting point.
115//!
116//! ## Process exposure
117//!
118//! A token in a process's environment is readable by any process running as
119//! the same user, and by `/proc/<pid>/environ` on Linux. Prefer reading from a
120//! credential store at startup over `std::env::var` in production binaries.
121//! Never use `env!` for a real token: that bakes the value into the binary at
122//! compile time.
123//!
124//! ## Logging discipline
125//!
126//! If you wrap SDK calls in `tracing` or similar, redact request bodies and
127//! headers. The streamer LOGIN frame serialises the auth token into JSON
128//! before transmission, so logging a constructed frame body leaks a bearer
129//! even though [`AuthToken`] redacts in its own `Debug`. Either keep
130//! frame-level logging off, or scrub by field.
131//!
132//! Secrets are only redacted within the newtypes. Only call `.expose_secret()`
133//! to get the raw value at the point of use instead of logging or storing it.
134//!
135//! ## Data at rest
136//!
137//! Zeroising on `Drop` does not protect against a debugger attached to the
138//! live process, a core dump that captures heap pages, or pages swapped to
139//! disk. If these are a concern, you should apply host-level hardening
140//! (e.g., encrypted swap).
141
142use secrecy::zeroize::Zeroize;
143use secrecy::{CloneableSecret, ExposeSecret, SecretBox, SecretString, SerializableSecret};
144use serde::{Deserialize, Serialize};
145
146/// Deserialize a [`String`] from either a JSON string or a JSON integer.
147///
148/// Schwab returns the same logical field as different JSON types across
149/// endpoints (e.g. `accountNumber` is a string on `securitiesAccount` and
150/// an `int64` on `Order`). This function accepts either form to prevent a
151/// parse error.
152fn deserialize_string_or_int<'de, D>(d: D) -> Result<String, D::Error>
153where
154 D: serde::Deserializer<'de>,
155{
156 struct V;
157 impl<'de> serde::de::Visitor<'de> for V {
158 type Value = String;
159
160 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 f.write_str("a string or integer")
162 }
163
164 fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<String, E> {
165 Ok(v.to_owned())
166 }
167
168 fn visit_string<E: serde::de::Error>(self, v: String) -> Result<String, E> {
169 Ok(v)
170 }
171
172 fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<String, E> {
173 Ok(v.to_string())
174 }
175
176 fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<String, E> {
177 Ok(v.to_string())
178 }
179 }
180
181 d.deserialize_any(V)
182}
183
184macro_rules! sensitive_string_newtype {
185 // Default: deserialize from a JSON string only.
186 ($(#[$meta:meta])* $vis:vis $name:ident, $inner:ident) => {
187 #[derive(Clone, Serialize, Deserialize)]
188 #[serde(transparent)]
189 struct $inner(String);
190
191 sensitive_string_newtype!(@common $(#[$meta])* $vis $name, $inner);
192 };
193
194 // Use a custom deserializer for the inner type.
195 ($(#[$meta:meta])* $vis:vis $name:ident, $inner:ident, deserialize_with = $de:path) => {
196 #[derive(Clone, Serialize)]
197 #[serde(transparent)]
198 struct $inner(String);
199
200 impl<'de> Deserialize<'de> for $inner {
201 fn deserialize<D>(d: D) -> std::result::Result<Self, D::Error>
202 where
203 D: serde::Deserializer<'de>,
204 {
205 $de(d).map($inner)
206 }
207 }
208
209 sensitive_string_newtype!(@common $(#[$meta])* $vis $name, $inner);
210 };
211
212 // Common expansion: the inner type, its zeroization, cloning, and serialization.
213 (@common $(#[$meta:meta])* $vis:vis $name:ident, $inner:ident) => {
214 impl Zeroize for $inner {
215 fn zeroize(&mut self) {
216 self.0.zeroize();
217 }
218 }
219
220 impl CloneableSecret for $inner {}
221 impl SerializableSecret for $inner {}
222
223 $(#[$meta])*
224 #[derive(Debug, Clone, Serialize, Deserialize)]
225 #[serde(transparent)]
226 $vis struct $name(SecretBox<$inner>);
227
228 impl $name {
229 /// Wrap a raw string in the redacting newtype.
230 pub fn new(value: impl Into<String>) -> Self {
231 Self(SecretBox::new(Box::new($inner(value.into()))))
232 }
233
234 /// Reveal the raw value. Use only at the point of constructing a
235 /// wire header, frame, or URL path segment; do not store, log,
236 /// or pass into untyped contexts.
237 pub fn expose_secret(&self) -> &str {
238 &self.0.expose_secret().0
239 }
240 }
241
242 impl From<&str> for $name {
243 fn from(value: &str) -> Self {
244 Self::new(value)
245 }
246 }
247
248 impl From<String> for $name {
249 fn from(value: String) -> Self {
250 Self::new(value)
251 }
252 }
253
254 impl From<SecretString> for $name {
255 fn from(value: SecretString) -> Self {
256 Self::new(value.expose_secret())
257 }
258 }
259 };
260}
261
262sensitive_string_newtype! {
263 /// OAuth bearer access token used in `Authorization: Bearer ...` headers
264 /// and in the streamer LOGIN frame's `Authorization` parameter.
265 ///
266 /// # Security
267 ///
268 /// Bearer credential with trading authority on a real-money
269 /// account. Wrapped in `secrecy::SecretBox`: `Debug` redacts and
270 /// `Drop` zeroises. Obtain the raw value via
271 /// [`expose_secret`](secrecy::ExposeSecret::expose_secret) only at
272 /// the point of use (header construction, LOGIN-frame
273 /// construction); do not store it in a plain `String`, do not
274 /// include it in error variants or log lines, and do not pass it
275 /// to a serializer that prints its input on error. See the
276 /// module-level threat model for what these properties do and do
277 /// not defend against.
278 pub AuthToken, AuthTokenInner
279}
280
281sensitive_string_newtype! {
282 /// `schwabClientCustomerId` from the user-preference endpoint. Echoed
283 /// back into every streamer request envelope.
284 ///
285 /// # Security
286 ///
287 /// PII linking a streamer session to a Schwab customer. Not itself
288 /// a bearer credential, but identifying enough that it should be
289 /// handled with the same care: do not log, do not surface in error
290 /// strings, do not write to disk outside an OS-native credential
291 /// store. `Debug` redacts and `Drop` zeroises; see the module-level
292 /// threat model for the limits of those properties.
293 pub CustomerId, CustomerIdInner
294}
295
296sensitive_string_newtype! {
297 /// Schwab account number. Appears in account-activity events and in
298 /// response bodies for account lookups.
299 ///
300 /// # Security
301 ///
302 /// PII at financial-account sensitivity. Not used in REST URL
303 /// paths - per-account endpoints take the encrypted
304 /// [`AccountHash`] instead - but does appear in response payloads
305 /// and streamer account-activity frames. Do not log, do not embed
306 /// in error strings, do not transmit to third-party services.
307 /// `Debug` redacts and `Drop` zeroises; see the module-level
308 /// threat model for the limits of those properties.
309 pub AccountNumber, AccountNumberInner, deserialize_with = deserialize_string_or_int
310}
311
312// Add impls for PartialEq, Eq, and Hash to the AccountNumber type so response
313// types that contain an `AccountNumber` can derive `PartialEq` / `Eq` / `Hash`.
314//
315// AccountNumber is sensitive enough that we don't want to accidentally log it,
316// but not so sensitive that we couldn't use it as a key in a HashMap.
317impl PartialEq for AccountNumber {
318 fn eq(&self, other: &Self) -> bool {
319 self.expose_secret() == other.expose_secret()
320 }
321}
322
323impl Eq for AccountNumber {}
324
325impl std::hash::Hash for AccountNumber {
326 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
327 self.expose_secret().hash(state);
328 }
329}
330
331sensitive_string_newtype! {
332 /// Encrypted account-number hash returned by `GET /accounts/accountNumbers`.
333 /// Schwab requires this value (not the plain account number) in the
334 /// `{accountNumber}` path segment of subsequent REST calls.
335 ///
336 /// # Security
337 ///
338 /// Account-linked identifier. Schwab encrypts the account number
339 /// before issuing this hash, so it is less directly sensitive than
340 /// [`AccountNumber`], but it is still a stable account identifier
341 /// that an attacker could use to correlate activity. Treat as PII:
342 /// do not log, do not include in error variants, do not share
343 /// outside the SDK boundary. `Debug` redacts and `Drop` zeroises;
344 /// see the module-level threat model for the limits of those
345 /// properties.
346 pub AccountHash, AccountHashInner
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn auth_token_debug_is_redacted() {
355 let token = AuthToken::new("super-secret-bearer");
356 let debug = format!("{token:?}");
357 assert!(
358 !debug.contains("super-secret-bearer"),
359 "Debug leaked secret: {debug}"
360 );
361 assert!(debug.contains("REDACTED"), "expected REDACTED in {debug}");
362 }
363
364 #[test]
365 fn auth_token_serializes_to_inner_string() {
366 let token = AuthToken::new("abc123");
367 let json = serde_json::to_string(&token).unwrap();
368 assert_eq!(json, r#""abc123""#);
369 }
370
371 #[test]
372 fn auth_token_round_trips_through_serde() {
373 let token = AuthToken::new("round-trip");
374 let json = serde_json::to_string(&token).unwrap();
375 let restored: AuthToken = serde_json::from_str(&json).unwrap();
376 assert_eq!(restored.expose_secret(), "round-trip");
377 }
378
379 #[test]
380 fn customer_id_debug_is_redacted() {
381 let id = CustomerId::new("CUST-001");
382 let debug = format!("{id:?}");
383 assert!(!debug.contains("CUST-001"));
384 assert!(debug.contains("REDACTED"));
385 }
386
387 #[test]
388 fn customer_id_round_trips() {
389 let id = CustomerId::new("CUST-001");
390 let json = serde_json::to_string(&id).unwrap();
391 assert_eq!(json, r#""CUST-001""#);
392 let restored: CustomerId = serde_json::from_str(&json).unwrap();
393 assert_eq!(restored.expose_secret(), "CUST-001");
394 }
395
396 #[test]
397 fn account_number_debug_is_redacted() {
398 let acct = AccountNumber::new("12345678");
399 let debug = format!("{acct:?}");
400 assert!(!debug.contains("12345678"));
401 assert!(debug.contains("REDACTED"));
402 }
403
404 #[test]
405 fn account_number_round_trips() {
406 let acct = AccountNumber::new("12345678");
407 let json = serde_json::to_string(&acct).unwrap();
408 assert_eq!(json, r#""12345678""#);
409 let restored: AccountNumber = serde_json::from_str(&json).unwrap();
410 assert_eq!(restored.expose_secret(), "12345678");
411 }
412
413 #[test]
414 fn account_number_deserializes_from_string_or_int() {
415 // Schwab's wire type varies across endpoints (string on
416 // `securitiesAccount`, `int64` on `Order`). Both must decode.
417 let from_str: AccountNumber = serde_json::from_str(r#""12345678""#).unwrap();
418 let from_int: AccountNumber = serde_json::from_str("12345678").unwrap();
419 assert_eq!(from_str.expose_secret(), "12345678");
420 assert_eq!(from_int.expose_secret(), "12345678");
421 assert_eq!(from_str, from_int);
422
423 let debug = format!("{from_int:?}");
424 assert!(!debug.contains("12345678"), "Debug leaked: {debug}");
425 assert!(debug.contains("REDACTED"), "expected REDACTED in {debug}");
426 }
427
428 #[test]
429 fn account_number_unexpected_type_produces_descriptive_error() {
430 let err = serde_json::from_str::<AccountNumber>("true").unwrap_err();
431 let msg = err.to_string();
432 assert!(
433 msg.contains("string") && msg.contains("integer"),
434 "missing expectation: {msg}",
435 );
436 assert!(msg.contains("bool"), "missing offending type: {msg}");
437 }
438
439 #[test]
440 fn account_hash_debug_is_redacted() {
441 let hash = AccountHash::new("ABCDEF0123456789");
442 let debug = format!("{hash:?}");
443 assert!(!debug.contains("ABCDEF0123456789"));
444 assert!(debug.contains("REDACTED"));
445 }
446
447 #[test]
448 fn account_hash_round_trips() {
449 let hash = AccountHash::new("ABCDEF0123456789");
450 let json = serde_json::to_string(&hash).unwrap();
451 assert_eq!(json, r#""ABCDEF0123456789""#);
452 let restored: AccountHash = serde_json::from_str(&json).unwrap();
453 assert_eq!(restored.expose_secret(), "ABCDEF0123456789");
454 }
455}