Skip to main content

max / makenotwork

synckit-client: zeroize the session bearer token Wrap the session JWT in a SecretToken newtype that scrubs its bytes on final drop, replacing the bare Arc<String> in Session and the public SessionInfo.token. Deref<str> and Display expose the raw value only for the Authorization header; Debug is redacted so it can't leak into logs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-17 17:07 UTC
Commit: 98c28a5832f19deb7bbb22b3d7160a25ccaaa009
Parent: 41639f8
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::{