返回 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 openrouter_vendor: Option<String>,
113 }
114
115 impl TurnRouteReceipt {
116 /// Mint a receipt from the values the installed client is bound to.
117 ///
118 /// `base_url` and `credential` are consumed here and never stored: only the
119 /// redacted endpoint identity and the one-way generation digest survive.
120 #[must_use]
121 pub fn new(
122 provider: ApiProvider,
123 provider_identity: &str,
124 wire_model: &str,
125 base_url: &str,
126 credential: &str,
127 ) -> Self {
128 Self {
129 provider,
130 provider_identity: provider_identity.trim().to_string(),
131 wire_model: wire_model.trim().to_string(),
132 endpoint_identity: endpoint_identity(base_url),
133 credential_generation: CredentialGeneration::derive(base_url, credential),
134 openrouter_vendor: None,
135 }
136 }
137
138 /// Keep upstream vendor restrictions frozen with the installed route.
139 #[must_use]
140 pub(crate) fn with_openrouter_vendor(mut self, vendor: Option<&str>) -> Self {
141 self.openrouter_vendor = vendor.map(str::to_string);
142 self
143 }
144
145 #[must_use]
146 pub(crate) fn openrouter_vendor(&self) -> Option<&str> {
147 self.openrouter_vendor.as_deref()
148 }
149
150 #[must_use]
151 pub fn provider(&self) -> ApiProvider {
152 self.provider
153 }
154
155 #[must_use]
156 pub fn provider_identity(&self) -> &str {
157 &self.provider_identity
158 }
159
160 #[must_use]
161 pub fn wire_model(&self) -> &str {
162 &self.wire_model
163 }
164
165 #[must_use]
166 pub fn endpoint_identity(&self) -> &str {
167 &self.endpoint_identity
168 }
169
170 #[must_use]
171 pub fn credential_generation(&self) -> &CredentialGeneration {
172 &self.credential_generation
173 }
174
175 /// Whether a live re-resolution of this route still lands on the same
176 /// endpoint and the same credential generation.
177 #[must_use]
178 pub fn matches_live_route(&self, base_url: &str, credential: &str) -> bool {
179 endpoint_identity(base_url) == self.endpoint_identity
180 && CredentialGeneration::derive(base_url, credential) == self.credential_generation
181 }
182 }
183
184 /// Redacted by construction: every field here is already non-secret, and the
185 /// generation digest renders as `<redacted>`.
186 impl fmt::Debug for TurnRouteReceipt {
187 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188 f.debug_struct("TurnRouteReceipt")
189 .field("provider", &self.provider)
190 .field("provider_identity", &self.provider_identity)
191 .field("wire_model", &self.wire_model)
192 .field("endpoint_identity", &self.endpoint_identity)
193 .field("credential_generation", &self.credential_generation)
194 .finish()
195 }
196 }
197
198 #[cfg(test)]
199 mod tests {
200 use super::{ApiProvider, CredentialGeneration, TurnRouteReceipt, endpoint_identity};
201
202 const USERINFO_URL: &str = "https://svc-user:hunter2@api.example.com/v1?api_key=sk-live-abc123\
203 &token=tok-secret-xyz&region=us-east";
204
205 #[test]
206 fn endpoint_identity_masks_userinfo_and_sensitive_query_values() {
207 let identity = endpoint_identity(USERINFO_URL);
208
209 for secret in ["svc-user", "hunter2", "sk-live-abc123", "tok-secret-xyz"] {
210 assert!(
211 !identity.contains(secret),
212 "endpoint identity leaked {secret}: {identity}"
213 );
214 }
215 // …and it is still a useful endpoint identity.
216 assert!(identity.contains("api.example.com"), "{identity}");
217 assert!(identity.contains("/v1"), "{identity}");
218 assert!(identity.contains("region=us-east"), "{identity}");
219 }
220
221 #[test]
222 fn endpoint_identity_folds_only_trailing_slashes() {
223 assert_eq!(
224 endpoint_identity("https://api.deepseek.com/v1/"),
225 endpoint_identity(" https://api.deepseek.com/v1 ")
226 );
227 assert_ne!(
228 endpoint_identity("https://api.deepseek.com/v1"),
229 endpoint_identity("https://api.deepseek.com/v2")
230 );
231 assert_ne!(
232 endpoint_identity("https://api.deepseek.com/v1"),
233 endpoint_identity("https://exfil.example.com/v1")
234 );
235 assert_ne!(
236 endpoint_identity("https://api.deepseek.com/v1"),
237 endpoint_identity("https://api.deepseek.com:8443/v1")
238 );
239 }
240
241 #[test]
242 fn unparseable_endpoints_never_render_a_path() {
243 let identity = endpoint_identity("/Users/someone/secret-project/socket");
244 assert!(!identity.contains("someone"), "{identity}");
245 assert!(!identity.contains("secret-project"), "{identity}");
246 assert_eq!(identity, "<opaque-endpoint>");
247 }
248
249 #[test]
250 fn debug_never_renders_credential_material() {
251 let receipt = TurnRouteReceipt::new(
252 ApiProvider::Deepseek,
253 "deepseek",
254 "deepseek-chat",
255 USERINFO_URL,
256 "sk-deepseek-secret",
257 );
258
259 for rendered in [
260 format!("{receipt:?}"),
261 format!("{receipt:#?}"),
262 format!("{:?}", receipt.credential_generation()),
263 ] {
264 for secret in [
265 "sk-deepseek-secret",
266 "hunter2",
267 "sk-live-abc123",
268 "tok-secret-xyz",
269 ] {
270 assert!(
271 !rendered.contains(secret),
272 "Debug leaked {secret}: {rendered}"
273 );
274 }
275 }
276 let rendered = format!("{receipt:?}");
277 assert!(rendered.contains("<redacted>"), "{rendered}");
278 assert!(rendered.contains("api.example.com"), "{rendered}");
279 assert!(rendered.contains("deepseek-chat"), "{rendered}");
280 }
281
282 #[test]
283 fn credential_generation_separates_endpoint_from_credential() {
284 // Length prefixing: no byte can be moved across the field boundary to
285 // forge a matching generation.
286 assert_ne!(
287 CredentialGeneration::derive("https://host/v1a", "bc"),
288 CredentialGeneration::derive("https://host/v1", "abc")
289 );
290 }
291
292 #[test]
293 fn matches_live_route_detects_userinfo_swap_behind_identical_redaction() {
294 let receipt = TurnRouteReceipt::new(
295 ApiProvider::Deepseek,
296 "deepseek",
297 "deepseek-chat",
298 "https://svc:original@api.deepseek.com/v1",
299 "sk-key",
300 );
301 // Same redacted endpoint identity, different real credentials in the
302 // URL. Redaction alone would call this a match; the generation digest
303 // does not.
304 assert_eq!(
305 endpoint_identity("https://svc:rotated@api.deepseek.com/v1"),
306 receipt.endpoint_identity()
307 );
308 assert!(!receipt.matches_live_route("https://svc:rotated@api.deepseek.com/v1", "sk-key"));
309 assert!(receipt.matches_live_route("https://svc:original@api.deepseek.com/v1", "sk-key"));
310 assert!(
311 !receipt.matches_live_route("https://svc:original@api.deepseek.com/v1", "sk-other")
312 );
313 }
314 }
315
315 lines RUST