返回 CodeWhale
route_receipt.rs
根目录 / crates / tui / src / route_receipt.rs
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&region=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
301 lines RUST