| 1 | //! Secret-free lifecycle receipts for the exact route a turn was launched on. |
| 2 | //! |
| 3 | //! A [`TurnRouteReceipt`] is minted from the **installed, preflighted** client — |
| 4 | //! the one that was actually constructed to serve the turn — and travels with |
| 5 | //! the turn's lifecycle event. Consumers that need to prove "this later request |
| 6 | //! goes to the same base route, with the same credential, as the turn it |
| 7 | //! descends from" compare receipts instead of re-reading mutable config. |
| 8 | //! |
| 9 | //! Everything in a receipt is safe to carry through events, state, and `Debug`: |
| 10 | //! |
| 11 | //! - the provider enum and the non-secret configured route key, |
| 12 | //! - the canonical wire model id, |
| 13 | //! - a **normalized, redacted** endpoint identity (URL userinfo and sensitive |
| 14 | //! query values masked by [`crate::client::redact_url_for_display`]), |
| 15 | //! - a one-way credential *generation* digest that is never rendered. |
| 16 | //! |
| 17 | //! The credential generation is deliberately taken over the endpoint **and** the |
| 18 | //! credential together. Redaction is lossy on purpose — `https://a:b@host/v1` |
| 19 | //! and `https://c:d@host/v1` share one endpoint identity — so folding the raw |
| 20 | //! endpoint into the digest is what keeps a userinfo swap detectable. |
| 21 | |
| 22 | use std::fmt; |
| 23 | |
| 24 | use sha2::{Digest, Sha256}; |
| 25 | |
| 26 | use crate::config::ApiProvider; |
| 27 | |
| 28 | /// Endpoint identity for a string that is not a parseable URL. |
| 29 | /// |
| 30 | /// Deliberately opaque: an unparseable endpoint could be a filesystem path, and |
| 31 | /// absolute paths must never reach an event, log, or `Debug` rendering. Two |
| 32 | /// different unparseable endpoints therefore collide here — the credential |
| 33 | /// generation digest below is what still tells them apart. |
| 34 | const OPAQUE_ENDPOINT: &str = "<opaque-endpoint>"; |
| 35 | |
| 36 | /// Normalized, redacted, comparable identity for an API endpoint. |
| 37 | /// |
| 38 | /// Safe to print. Trailing slashes are folded so `…/v1` and `…/v1/` are one |
| 39 | /// endpoint; nothing else is folded, so a host, scheme, port, or path change is |
| 40 | /// always a different identity. |
| 41 | #[must_use] |
| 42 | pub fn endpoint_identity(base_url: &str) -> String { |
| 43 | let trimmed = base_url.trim(); |
| 44 | if trimmed.is_empty() { |
| 45 | return String::new(); |
| 46 | } |
| 47 | if reqwest::Url::parse(trimmed).is_err() { |
| 48 | return OPAQUE_ENDPOINT.to_string(); |
| 49 | } |
| 50 | crate::client::redact_url_for_display(trimmed) |
| 51 | .trim_end_matches('/') |
| 52 | .to_string() |
| 53 | } |
| 54 | |
| 55 | /// One-way digest proving a credential (and the endpoint it is bound to) is |
| 56 | /// still the same one. |
| 57 | /// |
| 58 | /// SHA-256 over a domain-separated, length-prefixed preimage, truncated to 128 |
| 59 | /// bits. Length prefixing keeps `(base_url, key)` unambiguous, so no pair of |
| 60 | /// distinct routes can be made to share a generation by moving bytes across the |
| 61 | /// boundary. The value is never rendered: it is credential-derived, and a |
| 62 | /// stable public digest of a secret is a secret's shadow. |
| 63 | #[derive(Clone, PartialEq, Eq)] |
| 64 | pub struct CredentialGeneration(String); |
| 65 | |
| 66 | impl CredentialGeneration { |
| 67 | fn derive(base_url: &str, credential: &str) -> Self { |
| 68 | let mut hasher = Sha256::new(); |
| 69 | hasher.update(b"codewhale/turn-route/credential-generation/v1\0"); |
| 70 | hasher.update( |
| 71 | u64::try_from(base_url.len()) |
| 72 | .unwrap_or(u64::MAX) |
| 73 | .to_le_bytes(), |
| 74 | ); |
| 75 | hasher.update(base_url.as_bytes()); |
| 76 | hasher.update( |
| 77 | u64::try_from(credential.len()) |
| 78 | .unwrap_or(u64::MAX) |
| 79 | .to_le_bytes(), |
| 80 | ); |
| 81 | hasher.update(credential.as_bytes()); |
| 82 | let digest = hasher.finalize(); |
| 83 | let mut hex = String::with_capacity(32); |
| 84 | for byte in &digest[..16] { |
| 85 | use fmt::Write as _; |
| 86 | let _ = write!(hex, "{byte:02x}"); |
| 87 | } |
| 88 | Self(hex) |
| 89 | } |
| 90 | |
| 91 | #[must_use] |
| 92 | pub fn is_empty(&self) -> bool { |
| 93 | self.0.is_empty() |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | /// Redacted: see the type docs. There is no accessor for the digest string. |
| 98 | impl fmt::Debug for CredentialGeneration { |
| 99 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 100 | f.write_str("<redacted>") |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | /// Immutable proof of the exact base route a turn's client was installed on. |
| 105 | #[derive(Clone, PartialEq, Eq)] |
| 106 | pub struct TurnRouteReceipt { |
| 107 | provider: ApiProvider, |
| 108 | provider_identity: String, |
| 109 | wire_model: String, |
| 110 | endpoint_identity: String, |
| 111 | credential_generation: CredentialGeneration, |
| 112 | } |
| 113 | |
| 114 | impl TurnRouteReceipt { |
| 115 | /// Mint a receipt from the values the installed client is bound to. |
| 116 | /// |
| 117 | /// `base_url` and `credential` are consumed here and never stored: only the |
| 118 | /// redacted endpoint identity and the one-way generation digest survive. |
| 119 | #[must_use] |
| 120 | pub fn new( |
| 121 | provider: ApiProvider, |
| 122 | provider_identity: &str, |
| 123 | wire_model: &str, |
| 124 | base_url: &str, |
| 125 | credential: &str, |
| 126 | ) -> Self { |
| 127 | Self { |
| 128 | provider, |
| 129 | provider_identity: provider_identity.trim().to_string(), |
| 130 | wire_model: wire_model.trim().to_string(), |
| 131 | endpoint_identity: endpoint_identity(base_url), |
| 132 | credential_generation: CredentialGeneration::derive(base_url, credential), |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | #[must_use] |
| 137 | pub fn provider(&self) -> ApiProvider { |
| 138 | self.provider |
| 139 | } |
| 140 | |
| 141 | #[must_use] |
| 142 | pub fn provider_identity(&self) -> &str { |
| 143 | &self.provider_identity |
| 144 | } |
| 145 | |
| 146 | #[must_use] |
| 147 | pub fn wire_model(&self) -> &str { |
| 148 | &self.wire_model |
| 149 | } |
| 150 | |
| 151 | #[must_use] |
| 152 | pub fn endpoint_identity(&self) -> &str { |
| 153 | &self.endpoint_identity |
| 154 | } |
| 155 | |
| 156 | #[must_use] |
| 157 | pub fn credential_generation(&self) -> &CredentialGeneration { |
| 158 | &self.credential_generation |
| 159 | } |
| 160 | |
| 161 | /// Whether a live re-resolution of this route still lands on the same |
| 162 | /// endpoint and the same credential generation. |
| 163 | #[must_use] |
| 164 | pub fn matches_live_route(&self, base_url: &str, credential: &str) -> bool { |
| 165 | endpoint_identity(base_url) == self.endpoint_identity |
| 166 | && CredentialGeneration::derive(base_url, credential) == self.credential_generation |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | /// Redacted by construction: every field here is already non-secret, and the |
| 171 | /// generation digest renders as `<redacted>`. |
| 172 | impl fmt::Debug for TurnRouteReceipt { |
| 173 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 174 | f.debug_struct("TurnRouteReceipt") |
| 175 | .field("provider", &self.provider) |
| 176 | .field("provider_identity", &self.provider_identity) |
| 177 | .field("wire_model", &self.wire_model) |
| 178 | .field("endpoint_identity", &self.endpoint_identity) |
| 179 | .field("credential_generation", &self.credential_generation) |
| 180 | .finish() |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | #[cfg(test)] |
| 185 | mod tests { |
| 186 | use super::{ApiProvider, CredentialGeneration, TurnRouteReceipt, endpoint_identity}; |
| 187 | |
| 188 | const USERINFO_URL: &str = "https://svc-user:hunter2@api.example.com/v1?api_key=sk-live-abc123\ |
| 189 | &token=tok-secret-xyz®ion=us-east"; |
| 190 | |
| 191 | #[test] |
| 192 | fn endpoint_identity_masks_userinfo_and_sensitive_query_values() { |
| 193 | let identity = endpoint_identity(USERINFO_URL); |
| 194 | |
| 195 | for secret in ["svc-user", "hunter2", "sk-live-abc123", "tok-secret-xyz"] { |
| 196 | assert!( |
| 197 | !identity.contains(secret), |
| 198 | "endpoint identity leaked {secret}: {identity}" |
| 199 | ); |
| 200 | } |
| 201 | // …and it is still a useful endpoint identity. |
| 202 | assert!(identity.contains("api.example.com"), "{identity}"); |
| 203 | assert!(identity.contains("/v1"), "{identity}"); |
| 204 | assert!(identity.contains("region=us-east"), "{identity}"); |
| 205 | } |
| 206 | |
| 207 | #[test] |
| 208 | fn endpoint_identity_folds_only_trailing_slashes() { |
| 209 | assert_eq!( |
| 210 | endpoint_identity("https://api.deepseek.com/v1/"), |
| 211 | endpoint_identity(" https://api.deepseek.com/v1 ") |
| 212 | ); |
| 213 | assert_ne!( |
| 214 | endpoint_identity("https://api.deepseek.com/v1"), |
| 215 | endpoint_identity("https://api.deepseek.com/v2") |
| 216 | ); |
| 217 | assert_ne!( |
| 218 | endpoint_identity("https://api.deepseek.com/v1"), |
| 219 | endpoint_identity("https://exfil.example.com/v1") |
| 220 | ); |
| 221 | assert_ne!( |
| 222 | endpoint_identity("https://api.deepseek.com/v1"), |
| 223 | endpoint_identity("https://api.deepseek.com:8443/v1") |
| 224 | ); |
| 225 | } |
| 226 | |
| 227 | #[test] |
| 228 | fn unparseable_endpoints_never_render_a_path() { |
| 229 | let identity = endpoint_identity("/Users/someone/secret-project/socket"); |
| 230 | assert!(!identity.contains("someone"), "{identity}"); |
| 231 | assert!(!identity.contains("secret-project"), "{identity}"); |
| 232 | assert_eq!(identity, "<opaque-endpoint>"); |
| 233 | } |
| 234 | |
| 235 | #[test] |
| 236 | fn debug_never_renders_credential_material() { |
| 237 | let receipt = TurnRouteReceipt::new( |
| 238 | ApiProvider::Deepseek, |
| 239 | "deepseek", |
| 240 | "deepseek-chat", |
| 241 | USERINFO_URL, |
| 242 | "sk-deepseek-secret", |
| 243 | ); |
| 244 | |
| 245 | for rendered in [ |
| 246 | format!("{receipt:?}"), |
| 247 | format!("{receipt:#?}"), |
| 248 | format!("{:?}", receipt.credential_generation()), |
| 249 | ] { |
| 250 | for secret in [ |
| 251 | "sk-deepseek-secret", |
| 252 | "hunter2", |
| 253 | "sk-live-abc123", |
| 254 | "tok-secret-xyz", |
| 255 | ] { |
| 256 | assert!( |
| 257 | !rendered.contains(secret), |
| 258 | "Debug leaked {secret}: {rendered}" |
| 259 | ); |
| 260 | } |
| 261 | } |
| 262 | let rendered = format!("{receipt:?}"); |
| 263 | assert!(rendered.contains("<redacted>"), "{rendered}"); |
| 264 | assert!(rendered.contains("api.example.com"), "{rendered}"); |
| 265 | assert!(rendered.contains("deepseek-chat"), "{rendered}"); |
| 266 | } |
| 267 | |
| 268 | #[test] |
| 269 | fn credential_generation_separates_endpoint_from_credential() { |
| 270 | // Length prefixing: no byte can be moved across the field boundary to |
| 271 | // forge a matching generation. |
| 272 | assert_ne!( |
| 273 | CredentialGeneration::derive("https://host/v1a", "bc"), |
| 274 | CredentialGeneration::derive("https://host/v1", "abc") |
| 275 | ); |
| 276 | } |
| 277 | |
| 278 | #[test] |
| 279 | fn matches_live_route_detects_userinfo_swap_behind_identical_redaction() { |
| 280 | let receipt = TurnRouteReceipt::new( |
| 281 | ApiProvider::Deepseek, |
| 282 | "deepseek", |
| 283 | "deepseek-chat", |
| 284 | "https://svc:original@api.deepseek.com/v1", |
| 285 | "sk-key", |
| 286 | ); |
| 287 | // Same redacted endpoint identity, different real credentials in the |
| 288 | // URL. Redaction alone would call this a match; the generation digest |
| 289 | // does not. |
| 290 | assert_eq!( |
| 291 | endpoint_identity("https://svc:rotated@api.deepseek.com/v1"), |
| 292 | receipt.endpoint_identity() |
| 293 | ); |
| 294 | assert!(!receipt.matches_live_route("https://svc:rotated@api.deepseek.com/v1", "sk-key")); |
| 295 | assert!(receipt.matches_live_route("https://svc:original@api.deepseek.com/v1", "sk-key")); |
| 296 | assert!( |
| 297 | !receipt.matches_live_route("https://svc:original@api.deepseek.com/v1", "sk-other") |
| 298 | ); |
| 299 | } |
| 300 | } |
| 301 |