返回 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::{ProviderIdentity, ProviderKind};
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 pub(crate) 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 identity: ProviderIdentity,
108 wire_model: String,
109 endpoint_identity: String,
110 credential_generation: CredentialGeneration,
111 openrouter_vendor: Option<String>,
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(crate) fn from_admitted(
121 identity: &ProviderIdentity,
122 wire_model: &str,
123 base_url: &str,
124 credential: &str,
125 ) -> Self {
126 Self {
127 identity: identity.clone(),
128 wire_model: wire_model.trim().to_string(),
129 endpoint_identity: endpoint_identity(base_url),
130 credential_generation: CredentialGeneration::derive(base_url, credential),
131 openrouter_vendor: None,
132 }
133 }
134
135 /// Test facts do not read a runtime config; production mints from admission.
136 #[cfg(test)]
137 pub(crate) fn new(
138 provider: ProviderKind,
139 provider_identity: &str,
140 wire_model: &str,
141 base_url: &str,
142 credential: &str,
143 ) -> Self {
144 let identity = ProviderIdentity {
145 provider,
146 key: provider_identity.into(),
147 exact_id: Some(provider_identity.into()),
148 migrated_legacy_ollama_cloud_route: false,
149 legacy_root_custom_generation: None,
150 };
151 Self::from_admitted(&identity, wire_model, base_url, credential)
152 }
153
154 /// Non-executing fixture projection for cache scope tests. It uses the
155 /// canonical Config admission and only an already provable read-only
156 /// credential generation. An opaque source gets distinct synthetic facts;
157 /// the production lookup still refuses to reuse that evidence.
158 #[cfg(test)]
159 pub(crate) fn for_test_fixture(
160 config: &crate::config::Config,
161 kind: ProviderKind,
162 model: &str,
163 ) -> Self {
164 let identity = config.test_identity_for_kind(kind);
165 let base_url = config.base_url_for_route(&identity);
166 let generation = config
167 .readonly_health_credential_generation(&identity)
168 .unwrap_or_else(|| {
169 CredentialGeneration::derive(&base_url, "opaque-test-only-generation")
170 });
171 Self {
172 identity,
173 wire_model: model.to_string(),
174 endpoint_identity: endpoint_identity(&base_url),
175 credential_generation: generation,
176 openrouter_vendor: None,
177 }
178 }
179
180 /// Keep upstream vendor restrictions frozen with the installed route.
181 #[must_use]
182 pub(crate) fn with_openrouter_vendor(mut self, vendor: Option<&str>) -> Self {
183 self.openrouter_vendor = vendor.map(str::to_string);
184 self
185 }
186
187 #[must_use]
188 pub(crate) fn openrouter_vendor(&self) -> Option<&str> {
189 self.openrouter_vendor.as_deref()
190 }
191
192 #[must_use]
193 pub fn provider(&self) -> ProviderKind {
194 self.identity.provider
195 }
196
197 #[must_use]
198 pub fn provider_identity(&self) -> &str {
199 self.identity.key.as_str()
200 }
201
202 pub(crate) fn admitted_identity(&self) -> &ProviderIdentity {
203 &self.identity
204 }
205
206 #[must_use]
207 pub fn wire_model(&self) -> &str {
208 &self.wire_model
209 }
210
211 #[must_use]
212 pub fn endpoint_identity(&self) -> &str {
213 &self.endpoint_identity
214 }
215
216 #[must_use]
217 pub fn credential_generation(&self) -> &CredentialGeneration {
218 &self.credential_generation
219 }
220
221 /// Whether a live re-resolution of this route still lands on the same
222 /// endpoint and the same credential generation.
223 #[must_use]
224 pub fn matches_live_route(&self, base_url: &str, credential: &str) -> bool {
225 endpoint_identity(base_url) == self.endpoint_identity
226 && CredentialGeneration::derive(base_url, credential) == self.credential_generation
227 }
228 }
229
230 /// Redacted by construction: every field here is already non-secret, and the
231 /// generation digest renders as `<redacted>`.
232 impl fmt::Debug for TurnRouteReceipt {
233 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234 f.debug_struct("TurnRouteReceipt")
235 .field("provider", &self.identity.provider)
236 .field("provider_identity", &self.identity.key)
237 .field("wire_model", &self.wire_model)
238 .field("endpoint_identity", &self.endpoint_identity)
239 .field("credential_generation", &self.credential_generation)
240 .finish()
241 }
242 }
243
244 #[cfg(test)]
245 mod tests {
246 use super::{CredentialGeneration, ProviderKind, TurnRouteReceipt, endpoint_identity};
247
248 const USERINFO_URL: &str = "https://svc-user:hunter2@api.example.com/v1?api_key=sk-live-abc123\
249 &token=tok-secret-xyz&region=us-east";
250
251 #[test]
252 fn endpoint_identity_masks_userinfo_and_sensitive_query_values() {
253 let identity = endpoint_identity(USERINFO_URL);
254
255 for secret in ["svc-user", "hunter2", "sk-live-abc123", "tok-secret-xyz"] {
256 assert!(
257 !identity.contains(secret),
258 "endpoint identity leaked {secret}: {identity}"
259 );
260 }
261 // …and it is still a useful endpoint identity.
262 assert!(identity.contains("api.example.com"), "{identity}");
263 assert!(identity.contains("/v1"), "{identity}");
264 assert!(identity.contains("region=us-east"), "{identity}");
265 }
266
267 #[test]
268 fn endpoint_identity_folds_only_trailing_slashes() {
269 assert_eq!(
270 endpoint_identity("https://api.deepseek.com/v1/"),
271 endpoint_identity(" https://api.deepseek.com/v1 ")
272 );
273 assert_ne!(
274 endpoint_identity("https://api.deepseek.com/v1"),
275 endpoint_identity("https://api.deepseek.com/v2")
276 );
277 assert_ne!(
278 endpoint_identity("https://api.deepseek.com/v1"),
279 endpoint_identity("https://exfil.example.com/v1")
280 );
281 assert_ne!(
282 endpoint_identity("https://api.deepseek.com/v1"),
283 endpoint_identity("https://api.deepseek.com:8443/v1")
284 );
285 }
286
287 #[test]
288 fn unparseable_endpoints_never_render_a_path() {
289 let identity = endpoint_identity("/Users/someone/secret-project/socket");
290 assert!(!identity.contains("someone"), "{identity}");
291 assert!(!identity.contains("secret-project"), "{identity}");
292 assert_eq!(identity, "<opaque-endpoint>");
293 }
294
295 #[test]
296 fn debug_never_renders_credential_material() {
297 let receipt = TurnRouteReceipt::new(
298 ProviderKind::Deepseek,
299 "deepseek",
300 "deepseek-chat",
301 USERINFO_URL,
302 "sk-deepseek-secret",
303 );
304
305 for rendered in [
306 format!("{receipt:?}"),
307 format!("{receipt:#?}"),
308 format!("{:?}", receipt.credential_generation()),
309 ] {
310 for secret in [
311 "sk-deepseek-secret",
312 "hunter2",
313 "sk-live-abc123",
314 "tok-secret-xyz",
315 ] {
316 assert!(
317 !rendered.contains(secret),
318 "Debug leaked {secret}: {rendered}"
319 );
320 }
321 }
322 let rendered = format!("{receipt:?}");
323 assert!(rendered.contains("<redacted>"), "{rendered}");
324 assert!(rendered.contains("api.example.com"), "{rendered}");
325 assert!(rendered.contains("deepseek-chat"), "{rendered}");
326 }
327
328 #[test]
329 fn credential_generation_separates_endpoint_from_credential() {
330 // Length prefixing: no byte can be moved across the field boundary to
331 // forge a matching generation.
332 assert_ne!(
333 CredentialGeneration::derive("https://host/v1a", "bc"),
334 CredentialGeneration::derive("https://host/v1", "abc")
335 );
336 }
337
338 #[test]
339 fn matches_live_route_detects_userinfo_swap_behind_identical_redaction() {
340 let receipt = TurnRouteReceipt::new(
341 ProviderKind::Deepseek,
342 "deepseek",
343 "deepseek-chat",
344 "https://svc:original@api.deepseek.com/v1",
345 "sk-key",
346 );
347 // Same redacted endpoint identity, different real credentials in the
348 // URL. Redaction alone would call this a match; the generation digest
349 // does not.
350 assert_eq!(
351 endpoint_identity("https://svc:rotated@api.deepseek.com/v1"),
352 receipt.endpoint_identity()
353 );
354 assert!(!receipt.matches_live_route("https://svc:rotated@api.deepseek.com/v1", "sk-key"));
355 assert!(receipt.matches_live_route("https://svc:original@api.deepseek.com/v1", "sk-key"));
356 assert!(
357 !receipt.matches_live_route("https://svc:original@api.deepseek.com/v1", "sk-other")
358 );
359 }
360 }
361
361 lines RUST