max / makenotwork
5 files changed,
+80 insertions,
-18 deletions
| @@ -15,7 +15,7 @@ use crate::{ | |||
| 15 | 15 | types::*, | |
| 16 | 16 | }; | |
| 17 | 17 | ||
| 18 | - | use super::{Session, SyncKitClient, TOKEN_EXPIRY_BUFFER_SECS}; | |
| 18 | + | use super::{SecretToken, Session, SyncKitClient, TOKEN_EXPIRY_BUFFER_SECS}; | |
| 19 | 19 | use super::helpers::{check_response, jwt_exp, Idempotency}; | |
| 20 | 20 | ||
| 21 | 21 | impl SyncKitClient { | |
| @@ -62,7 +62,7 @@ impl SyncKitClient { | |||
| 62 | 62 | let token_exp = jwt_exp(&auth.token); | |
| 63 | 63 | ||
| 64 | 64 | *self.session.write() = Some(Session { | |
| 65 | - | token: Arc::new(auth.token), | |
| 65 | + | token: Arc::new(SecretToken::new(auth.token)), | |
| 66 | 66 | token_exp, | |
| 67 | 67 | user_id, | |
| 68 | 68 | app_id, | |
| @@ -79,7 +79,7 @@ impl SyncKitClient { | |||
| 79 | 79 | pub fn restore_session(&self, token: &str, user_id: UserId, app_id: AppId) { | |
| 80 | 80 | let token_exp = jwt_exp(token); | |
| 81 | 81 | *self.session.write() = Some(Session { | |
| 82 | - | token: Arc::new(token.to_string()), | |
| 82 | + | token: Arc::new(SecretToken::new(token.to_string())), | |
| 83 | 83 | token_exp, | |
| 84 | 84 | user_id, | |
| 85 | 85 | app_id, | |
| @@ -189,7 +189,7 @@ impl SyncKitClient { | |||
| 189 | 189 | let token_exp = jwt_exp(&token_resp.access_token); | |
| 190 | 190 | ||
| 191 | 191 | *self.session.write() = Some(Session { | |
| 192 | - | token: Arc::new(token_resp.access_token), | |
| 192 | + | token: Arc::new(SecretToken::new(token_resp.access_token)), | |
| 193 | 193 | token_exp, | |
| 194 | 194 | user_id, | |
| 195 | 195 | app_id, | |
| @@ -248,7 +248,7 @@ mod tests { | |||
| 248 | 248 | client.restore_session("fake-token", user_id, app_id); | |
| 249 | 249 | ||
| 250 | 250 | let info = client.session_info().expect("session should exist"); | |
| 251 | - | assert_eq!(*info.token, "fake-token"); | |
| 251 | + | assert_eq!(info.token.as_str(), "fake-token"); | |
| 252 | 252 | assert_eq!(info.user_id, user_id); | |
| 253 | 253 | assert_eq!(info.app_id, app_id); | |
| 254 | 254 | } | |
| @@ -262,7 +262,7 @@ mod tests { | |||
| 262 | 262 | client.restore_session("second-token", user_id, app_id); | |
| 263 | 263 | ||
| 264 | 264 | let info = client.session_info().unwrap(); | |
| 265 | - | assert_eq!(*info.token, "second-token"); | |
| 265 | + | assert_eq!(info.token.as_str(), "second-token"); | |
| 266 | 266 | } | |
| 267 | 267 | ||
| 268 | 268 | // ── build_authorize_url ── |
| @@ -15,7 +15,7 @@ use crate::{ | |||
| 15 | 15 | types::*, | |
| 16 | 16 | }; | |
| 17 | 17 | ||
| 18 | - | use super::SyncKitClient; | |
| 18 | + | use super::{SecretToken, SyncKitClient}; | |
| 19 | 19 | use super::helpers::{check_response, Idempotency}; | |
| 20 | 20 | ||
| 21 | 21 | impl SyncKitClient { | |
| @@ -149,7 +149,7 @@ impl SyncKitClient { | |||
| 149 | 149 | } | |
| 150 | 150 | ||
| 151 | 151 | /// Build the key endpoint URL and extract the bearer token. | |
| 152 | - | pub(super) fn key_url_and_token(&self) -> Result<(&str, Arc<String>)> { | |
| 152 | + | pub(super) fn key_url_and_token(&self) -> Result<(&str, Arc<SecretToken>)> { | |
| 153 | 153 | let token = self.require_token()?; | |
| 154 | 154 | Ok((&self.endpoints.keys, token)) | |
| 155 | 155 | } | |
| @@ -229,7 +229,7 @@ mod tests { | |||
| 229 | 229 | ||
| 230 | 230 | let (url, token) = client.key_url_and_token().unwrap(); | |
| 231 | 231 | assert_eq!(url, "https://example.com/api/v1/sync/keys"); | |
| 232 | - | assert_eq!(*token, "bearer-token"); | |
| 232 | + | assert_eq!(token.as_str(), "bearer-token"); | |
| 233 | 233 | } | |
| 234 | 234 | ||
| 235 | 235 | #[test] |
| @@ -174,9 +174,57 @@ impl Endpoints { | |||
| 174 | 174 | } | |
| 175 | 175 | } | |
| 176 | 176 | ||
| 177 | + | /// A bearer token whose bytes are scrubbed from memory when the last reference | |
| 178 | + | /// is dropped. | |
| 179 | + | /// | |
| 180 | + | /// The SDK holds the session's JWT for the lifetime of the session; wrapping it | |
| 181 | + | /// so its final drop zeroizes the heap keeps the credential from lingering after | |
| 182 | + | /// logout or session replacement. [`Deref`](std::ops::Deref) to `str` and | |
| 183 | + | /// [`Display`](std::fmt::Display) expose the raw value only where the HTTP layer | |
| 184 | + | /// needs it (the `Authorization: Bearer` header); [`Debug`](std::fmt::Debug) is | |
| 185 | + | /// redacted so the token cannot leak into a log line. | |
| 186 | + | pub struct SecretToken(String); | |
| 187 | + | ||
| 188 | + | impl SecretToken { | |
| 189 | + | pub(crate) fn new(token: String) -> Self { | |
| 190 | + | SecretToken(token) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /// The raw token string, for building the `Authorization` header. | |
| 194 | + | pub fn as_str(&self) -> &str { | |
| 195 | + | &self.0 | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | impl std::ops::Deref for SecretToken { | |
| 200 | + | type Target = str; | |
| 201 | + | fn deref(&self) -> &str { | |
| 202 | + | &self.0 | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | impl std::fmt::Display for SecretToken { | |
| 207 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 208 | + | f.write_str(&self.0) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | impl std::fmt::Debug for SecretToken { | |
| 213 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 214 | + | f.write_str("SecretToken(<redacted>)") | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | impl Drop for SecretToken { | |
| 219 | + | fn drop(&mut self) { | |
| 220 | + | use zeroize::Zeroize; | |
| 221 | + | self.0.zeroize(); | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 177 | 225 | /// Session state obtained after authentication. | |
| 178 | 226 | struct Session { | |
| 179 | - | token: Arc<String>, | |
| 227 | + | token: Arc<SecretToken>, | |
| 180 | 228 | /// Cached `exp` claim from the JWT, extracted once at session creation. | |
| 181 | 229 | token_exp: Option<i64>, | |
| 182 | 230 | user_id: UserId, | |
| @@ -185,8 +233,9 @@ struct Session { | |||
| 185 | 233 | ||
| 186 | 234 | /// Public session info returned by `session_info()`. | |
| 187 | 235 | pub struct SessionInfo { | |
| 188 | - | /// The JWT bearer token for API requests (shared ref-counted to avoid cloning). | |
| 189 | - | pub token: Arc<String>, | |
| 236 | + | /// The JWT bearer token for API requests (shared ref-counted to avoid | |
| 237 | + | /// cloning; zeroized when the last reference drops). | |
| 238 | + | pub token: Arc<SecretToken>, | |
| 190 | 239 | /// The authenticated user's UUID. | |
| 191 | 240 | pub user_id: UserId, | |
| 192 | 241 | /// The SyncKit app UUID this session belongs to. | |
| @@ -319,7 +368,7 @@ impl SyncKitClient { | |||
| 319 | 368 | /// Returns `NotAuthenticated` if no session exists. Also checks token | |
| 320 | 369 | /// expiry and returns `TokenExpired` if the JWT `exp` claim is within | |
| 321 | 370 | /// 30 seconds of the current time. | |
| 322 | - | pub(crate) fn require_token(&self) -> Result<Arc<String>> { | |
| 371 | + | pub(crate) fn require_token(&self) -> Result<Arc<SecretToken>> { | |
| 323 | 372 | let guard = self.session.read(); | |
| 324 | 373 | let session = guard.as_ref().ok_or(SyncKitError::NotAuthenticated)?; | |
| 325 | 374 | ||
| @@ -542,7 +591,20 @@ mod tests { | |||
| 542 | 591 | client.restore_session("my-token", user_id, app_id); | |
| 543 | 592 | ||
| 544 | 593 | let token = client.require_token().unwrap(); | |
| 545 | - | assert_eq!(*token, "my-token"); | |
| 594 | + | assert_eq!(token.as_str(), "my-token"); | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | #[test] | |
| 598 | + | fn secret_token_exposes_value_but_redacts_debug() { | |
| 599 | + | let t = SecretToken::new("super-secret-jwt".to_string()); | |
| 600 | + | // The raw value is reachable exactly where the HTTP layer needs it. | |
| 601 | + | assert_eq!(t.as_str(), "super-secret-jwt"); | |
| 602 | + | assert_eq!(&*t, "super-secret-jwt"); // Deref to str | |
| 603 | + | assert_eq!(t.to_string(), "super-secret-jwt"); // Display, for bearer_auth | |
| 604 | + | // ...but Debug must never spill it into a log line. | |
| 605 | + | let debug = format!("{t:?}"); | |
| 606 | + | assert!(!debug.contains("super-secret-jwt"), "Debug leaked the token: {debug}"); | |
| 607 | + | assert!(debug.contains("redacted")); | |
| 546 | 608 | } | |
| 547 | 609 | ||
| 548 | 610 | // ── require_session_ids ── |
| @@ -13,7 +13,7 @@ use rand::Rng; | |||
| 13 | 13 | ||
| 14 | 14 | use crate::error::{Result, SyncKitError}; | |
| 15 | 15 | ||
| 16 | - | use super::SyncKitClient; | |
| 16 | + | use super::{SecretToken, SyncKitClient}; | |
| 17 | 17 | ||
| 18 | 18 | /// A stream of SSE "changed" notifications from the SyncKit server. | |
| 19 | 19 | /// | |
| @@ -31,7 +31,7 @@ pub struct SyncNotifyStream { | |||
| 31 | 31 | response: Option<reqwest::Response>, | |
| 32 | 32 | http: reqwest::Client, | |
| 33 | 33 | url: String, | |
| 34 | - | token: Arc<String>, | |
| 34 | + | token: Arc<SecretToken>, | |
| 35 | 35 | reconnect_attempt: u32, | |
| 36 | 36 | } | |
| 37 | 37 | ||
| @@ -67,7 +67,7 @@ impl SyncNotifyStream { | |||
| 67 | 67 | pub(crate) fn new( | |
| 68 | 68 | http: reqwest::Client, | |
| 69 | 69 | url: String, | |
| 70 | - | token: Arc<String>, | |
| 70 | + | token: Arc<SecretToken>, | |
| 71 | 71 | response: reqwest::Response, | |
| 72 | 72 | ) -> Self { | |
| 73 | 73 | Self { |
| @@ -79,7 +79,7 @@ pub mod store; | |||
| 79 | 79 | pub mod types; | |
| 80 | 80 | ||
| 81 | 81 | // Re-exports for convenience | |
| 82 | - | pub use client::{validate_api_key, SessionInfo, SyncKitClient, SyncKitConfig, SyncNotifyStream}; | |
| 82 | + | pub use client::{validate_api_key, SecretToken, SessionInfo, SyncKitClient, SyncKitConfig, SyncNotifyStream}; | |
| 83 | 83 | pub use client::{OtaArtifactUpload, OtaManifest, OtaRelease}; | |
| 84 | 84 | pub use oauth::{generate_oauth_state, generate_pkce, states_match, Pkce}; | |
| 85 | 85 | pub use client::subscription::{ |