Skip to main content

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}