diff --git a/frontend/bun.lock b/frontend/bun.lock index 097a2a173..c4824bcc1 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -6,6 +6,7 @@ "name": "maple", "dependencies": { "@opensecret/react": "file:../sdk", + "@opensecret/react-v1": "npm:@opensecret/react@3.4.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -249,6 +250,8 @@ "@opensecret/react": ["@opensecret/react@file:../sdk", { "dependencies": { "@peculiar/x509": "1.14.3", "@stablelib/base64": "2.0.1", "@stablelib/chacha20poly1305": "2.0.1", "@stablelib/random": "2.0.1", "cbor2": "1.12.0", "tweetnacl": "1.0.3", "zod": "3.25.76" }, "devDependencies": { "@eslint/js": "9.39.5", "@noble/curves": "1.9.7", "@noble/hashes": "1.8.0", "@types/bun": "1.1.13", "@types/react": "19.2.18", "@vitejs/plugin-react": "4.7.0", "ajv": "8.20.0", "eslint": "9.39.5", "eslint-plugin-react-hooks": "5.2.0", "globals": "15.15.0", "openai": "5.23.2", "prettier": "3.9.6", "typescript": "5.6.3", "typescript-eslint": "8.66.0", "vite": "6.4.3", "vite-plugin-dts": "4.5.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }], + "@opensecret/react-v1": ["@opensecret/react@3.4.1", "", { "dependencies": { "@peculiar/x509": "1.14.3", "@stablelib/base64": "2.0.1", "@stablelib/chacha20poly1305": "2.0.1", "@stablelib/random": "2.0.1", "cbor2": "1.12.0", "tweetnacl": "1.0.3", "zod": "3.25.76" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-9jcmjIljgw5ufHHV5Q/qFXT+B6bnjupCK1zpzGNuyohEcllwQubcdO+tkpZpCu6NgSJUIoe/XgKamMa+A6aFPw=="], + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "@peculiar/asn1-x509-attr": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA=="], "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg=="], @@ -1345,6 +1348,8 @@ "@opensecret/react/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@opensecret/react-v1/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/frontend/package.json b/frontend/package.json index d77f01f97..b1a8cd4f7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,6 +43,7 @@ }, "dependencies": { "@opensecret/react": "file:../sdk", + "@opensecret/react-v1": "npm:@opensecret/react@3.4.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index bb2119719..cdd5077ca 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -4759,6 +4759,7 @@ dependencies = [ "tokio", "tokio-util", "tower-http 0.6.8", + "uuid", "webpki-roots", "windows 0.62.2", "zip 8.6.0", @@ -4766,7 +4767,7 @@ dependencies = [ [[package]] name = "maple-proxy" -version = "0.3.4" +version = "0.4.0" dependencies = [ "anyhow", "async-stream", @@ -5583,7 +5584,7 @@ dependencies = [ [[package]] name = "opensecret" -version = "3.6.2" +version = "4.0.0" dependencies = [ "aes-gcm", "anyhow", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index d7bb8bcd0..62ada14f7 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -51,6 +51,8 @@ base64 = "0.22" reqwest = { version = "0.13", features = ["stream"] } futures-util = "0.3" sha2 = "0.10" +opensecret = { version = "4.0.0", path = "../../sdk/rust" } +uuid = { version = "1.11", features = ["v4", "serde"] } [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux", target_os = "android"))'.dependencies] # PDF OCR uses Maple's explicitly packaged ONNX Runtime. The loader policy @@ -63,8 +65,7 @@ ort = { version = "=2.0.0-rc.11", default-features = false, features = ["std", " # history. goose = { git = "https://github.com/aaif-goose/goose.git", rev = "f9c7aaccde4834810dfd13d5efa8f0d39ba28a20", package = "goose", default-features = false } goose-providers = { git = "https://github.com/aaif-goose/goose.git", rev = "f9c7aaccde4834810dfd13d5efa8f0d39ba28a20", package = "goose-providers", default-features = false } -maple-proxy = { version = "0.3.3", path = "../../proxy" } -opensecret = { version = "3.6.2", path = "../../sdk/rust" } +maple-proxy = { version = "0.4.0", path = "../../proxy" } axum = "0.8" tower-http = { version = "0.6", features = ["cors"] } rand = "0.8.6" diff --git a/frontend/src-tauri/src/agent/provider.rs b/frontend/src-tauri/src/agent/provider.rs index 2824f2e89..8c10cd41c 100644 --- a/frontend/src-tauri/src/agent/provider.rs +++ b/frontend/src-tauri/src/agent/provider.rs @@ -11,7 +11,7 @@ use goose_providers::images::ImageFormat; use goose_providers::model::ModelConfig; use goose_providers::request_log::{start_log, LoggerHandleExt}; use goose_providers::retry::{ - should_retry, RetryConfig, DEFAULT_BACKOFF_MULTIPLIER, DEFAULT_INITIAL_RETRY_INTERVAL_MS, + RetryConfig, DEFAULT_BACKOFF_MULTIPLIER, DEFAULT_INITIAL_RETRY_INTERVAL_MS, DEFAULT_MAX_RETRY_INTERVAL_MS, }; use opensecret::{InferenceRequest, InferenceResponse, OpenSecretClient, OpenSecretResponseBody}; @@ -37,9 +37,6 @@ const ERROR_CODE_HEADER: &str = "x-opensecret-error-code"; const ERROR_CONTRACT_VERSION: &[u8] = b"1"; const SESSION_NOT_FOUND_ERROR_CODE: &[u8] = b"session_not_found"; -/// Extra transient inference attempts after the first failure. Goose's 1s × 2^n -/// backoff, capped at 30s, then covers roughly three minutes of provider blips. -const TRANSIENT_MAX_RETRIES: usize = 10; const KIMI_K3_MODEL_ID: &str = "kimi-k3"; // Agent Mode forwards the selected catalog ID unchanged for direct model // selections. Keep Gemma's provider-specific opt-in scoped to that explicit @@ -159,8 +156,6 @@ impl MapleInferenceTransport for OpenSecretClient { pub(crate) struct MapleProvider { transport: Arc, - #[cfg(test)] - test_retry_config: Option, } impl MapleProvider { @@ -168,16 +163,13 @@ impl MapleProvider { where T: MapleInferenceTransport + 'static, { - Self { - transport, - #[cfg(test)] - test_retry_config: None, - } + Self { transport } } #[cfg(test)] - fn with_test_retry_config(mut self, retry_config: RetryConfig) -> Self { - self.test_retry_config = Some(retry_config); + fn with_test_retry_config(self, _retry_config: RetryConfig) -> Self { + // Retained only to keep unrelated fixtures concise. Transport v2 has + // no retry mode until the SDK can prove a failure happened pre-send. self } @@ -383,65 +375,39 @@ impl MapleProvider { )) } - async fn stream_with_retry( + async fn stream_once( &self, payload_bytes: &[u8], cancellation: &CancellationToken, ) -> Result { - let config = Provider::retry_config(self); - let mut attempts = 0; - - loop { - let error = match self.stream_attempt(payload_bytes, cancellation).await { - Ok(mut stream) => { - // TODO(upstream): Remove this Maple-specific bridge from Agent Mode once - // Maple's pinned Goose revision provides equivalent first-item handling: - // https://github.com/aaif-goose/goose/issues/10887 - // If auxiliary complete() calls still need this protection, scope it to - // that path instead. Recovery after any successful item remains out of - // scope here: - // https://github.com/aaif-goose/goose/issues/10897 - let first = tokio::select! { - biased; - _ = cancellation.cancelled() => return Err(cancellation_error()), - first = stream.next() => first, - }; - match first { - Some(Ok(first)) => { - return Ok(Box::pin( - futures_util::stream::once(ready(Ok(first))).chain(stream), - )); - } - Some(Err(error)) => error, - None => return Ok(stream), - } - } - Err(error) => error, - }; - - if !should_retry(&error, &config) || attempts >= config.max_retries() { + let mut stream = match self.stream_attempt(payload_bytes, cancellation).await { + Ok(stream) => stream, + Err(error) => { remember_terminal_run_error(&error); return Err(error); } - attempts += 1; - let delay = match &error { - ProviderError::RateLimitExceeded { - retry_delay: Some(provider_delay), - .. - } => *provider_delay, - _ => config.delay_for_attempt(attempts), - }; - let skip_backoff = std::env::var("GOOSE_PROVIDER_SKIP_BACKOFF") - .unwrap_or_default() - .parse::() - .unwrap_or(false); - if !skip_backoff { - tokio::select! { - biased; - _ = cancellation.cancelled() => return Err(cancellation_error()), - _ = tokio::time::sleep(delay) => {} - } + }; + + // Pull the first parsed item before handing the stream to Goose so an + // invalid initial record remains a request failure rather than an + // apparently successful start. The request may already have reached + // the enclave at every failure point after `stream_attempt` begins, so + // Maple never repeats it. A future pre-send retry requires an explicit + // SDK delivery-state signal rather than inference from error classes. + let first = tokio::select! { + biased; + _ = cancellation.cancelled() => return Err(cancellation_error()), + first = stream.next() => first, + }; + match first { + Some(Ok(first)) => Ok(Box::pin( + futures_util::stream::once(ready(Ok(first))).chain(stream), + )), + Some(Err(error)) => { + remember_terminal_run_error(&error); + Err(error) } + None => Ok(stream), } } @@ -463,7 +429,7 @@ impl MapleProvider { let cancellation = current_run_cancellation(); let stream = self - .stream_with_retry(&payload_bytes, &cancellation) + .stream_once(&payload_bytes, &cancellation) .await .inspect_err(|error| { let _ = request_log.error(error); @@ -496,16 +462,11 @@ impl Provider for MapleProvider { } fn retry_config(&self) -> RetryConfig { - #[cfg(test)] - if let Some(config) = &self.test_retry_config { - return config.clone(); - } - - // Retrying deterministic client failures can repeat side effects and - // causes the SDK to repeat its own stale-session recovery for a 400. One - // shared transient budget covers both setup and pre-first-item failures. + // Goose must not wrap Maple's single-send transport in another retry. + // The SDK currently exposes no proof that a failed inference remained + // pre-send, so every transport failure is treated as ambiguous. RetryConfig::new( - TRANSIENT_MAX_RETRIES, + 0, DEFAULT_INITIAL_RETRY_INTERVAL_MS, DEFAULT_BACKOFF_MULTIPLIER, DEFAULT_MAX_RETRY_INTERVAL_MS, @@ -1000,7 +961,6 @@ mod tests { method: String, uri: String, accept: Option, - raw_body: Vec, body: Value, } @@ -1082,7 +1042,6 @@ mod tests { .get("accept") .and_then(|value| value.to_str().ok()) .map(str::to_owned), - raw_body: body.to_vec(), body: serde_json::from_slice(&body).expect("request body should be JSON"), }; self.requests.lock().expect("request lock").push(captured); @@ -1287,16 +1246,6 @@ mod tests { response } - fn notifying_malformed_response(error_read: Arc) -> InferenceResponse { - let body: OpenSecretResponseBody = Box::pin(futures_util::stream::once(async move { - error_read.notify_one(); - Ok(b"data: transient-invalid-stream\n\n".to_vec().into()) - })); - let mut response = InferenceResponse::new(body); - *response.status_mut() = tauri::http::StatusCode::OK; - response - } - #[tokio::test] async fn formats_openai_request_and_preserves_images_and_thinking() { let transport = Arc::new(FakeTransport::new(fragmented_success_response())); @@ -1668,7 +1617,7 @@ mod tests { } #[tokio::test] - async fn retries_invalid_stream_before_first_item_with_the_same_request() { + async fn does_not_retry_an_invalid_stream_before_the_first_item() { let transport = Arc::new(FakeTransport::queued(vec![ malformed_response("transient-invalid-stream"), fragmented_success_response(), @@ -1676,33 +1625,23 @@ mod tests { let provider = MapleProvider::new(Arc::clone(&transport)).with_test_retry_config(fast_retry_config(3)); - let stream = provider + let result = provider .stream( &ModelConfig::new("test-model"), "system", &[Message::user().with_text("hello")], &[], ) - .await - .expect("replacement stream should start"); - let (message, usage) = collect_stream(stream) - .await - .expect("replacement stream should parse"); - let text = message - .content - .iter() - .filter_map(|content| match content { - MessageContent::Text(text) => Some(text.text.as_str()), - _ => None, - }) - .collect::(); + .await; - assert_eq!(text, "Hello world"); - assert_eq!(usage.usage.total_tokens, Some(5)); - let requests = transport.requests.lock().expect("request lock"); - assert_eq!(requests.len(), 2); - assert_eq!(requests[0].raw_body, requests[1].raw_body); - assert_eq!(requests[0].body, requests[1].body); + assert_eq!( + result.err(), + Some(ProviderError::NetworkError( + "Maple's response stream was invalid".to_string() + )) + ); + assert_eq!(transport.request_count(), 1); + assert_eq!(transport.remaining_response_count(), 1); } #[tokio::test] @@ -1818,22 +1757,16 @@ mod tests { } #[tokio::test] - async fn shares_one_retry_budget_across_status_and_first_item_failures() { - let mut responses = vec![response( - 503, - vec![br#"{"error":{"message":"temporarily unavailable"}}"#.to_vec()], - None, - )]; - responses.extend( - (0..TRANSIENT_MAX_RETRIES) - .map(|index| malformed_response(&format!("invalid-stream-{index}"))), - ); - responses.push(fragmented_success_response()); - let transport = Arc::new(FakeTransport::queued(responses)); + async fn does_not_retry_a_server_response_that_may_follow_dispatch() { + let transport = Arc::new(FakeTransport::queued(vec![ + response( + 503, + vec![br#"{"error":{"message":"temporarily unavailable"}}"#.to_vec()], + None, + ), + fragmented_success_response(), + ])); let provider = MapleProvider::new(Arc::clone(&transport)); - let default_max_retries = Provider::retry_config(&provider).max_retries(); - assert_eq!(default_max_retries, TRANSIENT_MAX_RETRIES); - let provider = provider.with_test_retry_config(fast_retry_config(default_max_retries)); let result = provider .stream( @@ -1844,20 +1777,21 @@ mod tests { ) .await; let error = match result { - Ok(_) => panic!("the shared retry budget should be exhausted"), + Ok(_) => panic!("the server failure should be surfaced"), Err(error) => error, }; assert_eq!( error, - ProviderError::NetworkError("Maple's response stream was invalid".to_string()) + ProviderError::ServerError("Maple's server returned status 503".to_string()) ); - assert_eq!(transport.request_count(), TRANSIENT_MAX_RETRIES + 1); + assert_eq!(Provider::retry_config(&provider).max_retries(), 0); + assert_eq!(transport.request_count(), 1); assert_eq!(transport.remaining_response_count(), 1); } #[tokio::test] - async fn retries_an_incomplete_tool_call_before_it_is_yielded() { + async fn does_not_retry_an_incomplete_tool_call_before_it_is_yielded() { let interrupted = response_with_items( 200, vec![ @@ -1871,38 +1805,23 @@ mod tests { let provider = MapleProvider::new(Arc::clone(&transport)).with_test_retry_config(fast_retry_config(3)); - let stream = provider + let result = provider .stream( &ModelConfig::new("test-model"), "system", &[Message::user().with_text("search")], &[], ) - .await - .expect("replacement tool stream should start"); - let (message, usage) = collect_stream(stream) - .await - .expect("replacement tool stream should parse"); - let calls = message - .content - .iter() - .filter_map(|content| match content { - MessageContent::ToolRequest(request) => request.tool_call.as_ref().ok(), - _ => None, - }) - .collect::>(); + .await; - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].name, "web_search"); assert_eq!( - calls[0] - .arguments - .as_ref() - .and_then(|arguments| arguments.get("query")), - Some(&json!("maple")) + result.err(), + Some(ProviderError::NetworkError( + "Maple's response stream was invalid".to_string() + )) ); - assert_eq!(usage.usage.total_tokens, Some(5)); - assert_eq!(transport.request_count(), 2); + assert_eq!(transport.request_count(), 1); + assert_eq!(transport.remaining_response_count(), 1); } #[tokio::test] @@ -2533,35 +2452,29 @@ mod tests { } #[tokio::test] - async fn cancellation_interrupts_first_item_retry_backoff() { - let error_read = Arc::new(Notify::new()); + async fn first_item_failure_returns_without_retry_backoff() { let transport = Arc::new(FakeTransport::queued(vec![ - notifying_malformed_response(Arc::clone(&error_read)), + malformed_response("invalid-first-item"), fragmented_success_response(), ])); - let retry_config = RetryConfig::new(3, 60_000, 1.0, 60_000).transient_only(); - let provider = - MapleProvider::new(Arc::clone(&transport)).with_test_retry_config(retry_config); + let provider = MapleProvider::new(Arc::clone(&transport)); let cancellation = CancellationToken::new(); let model_config = ModelConfig::new("test-model"); let messages = [Message::user().with_text("hello")]; - let stream = with_run_cancellation( - cancellation.clone(), - provider.stream(&model_config, "system", &messages, &[]), - ); - tokio::pin!(stream); - - tokio::select! { - _ = error_read.notified() => {} - result = &mut stream => panic!("stream unexpectedly finished before cancellation: {}", result.is_ok()), - } - - cancellation.cancel(); - let result = tokio::time::timeout(Duration::from_secs(1), stream) - .await - .expect("cancellation should interrupt backoff"); - assert!( - matches!(result, Err(ProviderError::ExecutionError(message)) if message.contains("cancelled")) + let result = tokio::time::timeout( + Duration::from_secs(1), + with_run_cancellation( + cancellation, + provider.stream(&model_config, "system", &messages, &[]), + ), + ) + .await + .expect("the first-item error must not enter retry backoff"); + assert_eq!( + result.err(), + Some(ProviderError::NetworkError( + "Maple's response stream was invalid".to_string() + )) ); assert_eq!(transport.request_count(), 1); assert_eq!(transport.remaining_response_count(), 1); diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 19d9a54e6..9c8818a96 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -13,8 +13,8 @@ mod agent_tauri; mod legacy_tts_cleanup; #[cfg(desktop)] mod maple_api; +mod native_oauth; mod onnxruntime; -#[cfg(desktop)] mod open_secret_config; mod pdf_extractor; mod pdf_ocr; @@ -234,7 +234,8 @@ fn handle_desktop_run_event(app_handle: &tauri::AppHandle, event: tauri::RunEven // This handles incoming deep links fn handle_deep_link_event(url: &str, app: &tauri::AppHandle) { - // OAuth callbacks carry bearer tokens in the query string, so never log the raw URL. + // Authentication callbacks carry a short-lived session-bound grant in the + // query string, so never log the raw URL. log::info!("[Deep Link] Received callback"); #[cfg(desktop)] reveal_main_window(app); @@ -250,7 +251,7 @@ pub fn run() { #[cfg(desktop)] let app = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { - // argv can contain a custom-scheme callback URL with OAuth bearer tokens. + // argv can contain a custom-scheme callback URL with an OAuth handoff grant. log::info!("Single instance detected for {}", app.package_info().name); reveal_main_window(app); })) @@ -263,6 +264,7 @@ pub fn run() { .manage(agent_acp::AgentAcpState::new()) .manage(agent_host::AgentHostLifecycle::new()) .manage(maple_api::MapleApiAuthState::new()) + .manage(native_oauth::NativeOAuthState::new()) .manage(proxy::ProxyState::new()) .invoke_handler(tauri::generate_handler![ agent_tauri::agent_get_runtime_status, @@ -307,6 +309,9 @@ pub fn run() { maple_api::maple_api_set_auth, maple_api::maple_api_get_auth, maple_api::maple_api_clear_auth, + native_oauth::native_oauth_begin, + native_oauth::native_oauth_redeem, + native_oauth::native_oauth_cancel, proxy::start_proxy, proxy::stop_proxy, proxy::stop_and_reset_proxy, @@ -551,7 +556,8 @@ pub fn run() { .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_os::init()) - .plugin(tauri_plugin_fs::init()); + .plugin(tauri_plugin_fs::init()) + .manage(native_oauth::NativeOAuthState::new()); // Only add the Apple Sign In plugin on iOS #[cfg(all(not(desktop), target_os = "ios"))] @@ -564,6 +570,9 @@ pub fn run() { let app = builder .invoke_handler(tauri::generate_handler![ pdf_extractor::extract_document_content, + native_oauth::native_oauth_begin, + native_oauth::native_oauth_redeem, + native_oauth::native_oauth_cancel, ]) .setup(|app| { // Set up the deep link handler for mobile @@ -586,6 +595,9 @@ pub fn run() { let app = builder .invoke_handler(tauri::generate_handler![ pdf_extractor::extract_document_content, + native_oauth::native_oauth_begin, + native_oauth::native_oauth_redeem, + native_oauth::native_oauth_cancel, ]) .setup(|app| { legacy_tts_cleanup::schedule(app.handle()); diff --git a/frontend/src-tauri/src/maple_api.rs b/frontend/src-tauri/src/maple_api.rs index 35a2429b8..4bb20f283 100644 --- a/frontend/src-tauri/src/maple_api.rs +++ b/frontend/src-tauri/src/maple_api.rs @@ -1,4 +1,4 @@ -use crate::open_secret_config::configured_pcr0_environment; +use crate::open_secret_config::{configured_pcr0_environment, normalize_api_url}; use opensecret::{ InferenceRequest, InferenceResponse, OpenSecretClient, WebExtractRequest, WebExtractResponse, WebSearchRequest, WebSearchResponse, @@ -14,21 +14,19 @@ use tokio_util::sync::CancellationToken; const AUTH_CHANGED_EVENT: &str = "maple-api-auth-changed"; const CREDENTIAL_VALIDATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); -#[derive(Debug, Clone, Deserialize)] +#[derive(Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MapleApiAuthRequest { pub user_id: String, pub api_url: String, - pub access_token: String, - pub refresh_token: Option, + pub auth_bundle: String, } -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct MapleApiAuthSnapshot { pub user_id: String, - pub access_token: String, - pub refresh_token: Option, + pub auth_bundle: String, pub native_instance_id: String, pub revision: u64, } @@ -38,21 +36,23 @@ pub struct MapleApiAuthSnapshot { struct MapleApiAuthChanged { user_id: String, revision: u64, + authenticated: bool, } trait MapleApiAuthEventSink: Send + Sync { - fn auth_changed(&self, user_id: &str, revision: u64); + fn auth_changed(&self, user_id: &str, revision: u64, authenticated: bool); } struct TauriAuthEventSink(AppHandle); impl MapleApiAuthEventSink for TauriAuthEventSink { - fn auth_changed(&self, user_id: &str, revision: u64) { + fn auth_changed(&self, user_id: &str, revision: u64, authenticated: bool) { if let Err(error) = self.0.emit( AUTH_CHANGED_EVENT, MapleApiAuthChanged { user_id: user_id.to_string(), revision, + authenticated, }, ) { log::warn!("Failed to notify Maple of refreshed API credentials: {error}"); @@ -92,7 +92,7 @@ pub(crate) struct MapleApiAuthLease<'a> { struct ClientSnapshot { generation: u64, client: Arc, - tokens_before: TokenPair, + auth_bundle_before: String, } struct CancelOperationOnDrop(CancellationToken); @@ -103,12 +103,6 @@ impl Drop for CancelOperationOnDrop { } } -#[derive(Clone, PartialEq, Eq)] -struct TokenPair { - access_token: String, - refresh_token: Option, -} - impl MapleApiSession { fn new( event_sink: Arc, @@ -118,7 +112,7 @@ impl MapleApiSession { api_url: String, client: Arc, ) -> Result { - capture_tokens(&client)?; + capture_auth_bundle(&client)?; Ok(Self { user_id, account_scope, @@ -145,14 +139,14 @@ impl MapleApiSession { api_url: String, client: Arc, ) -> Result { - let replacement_tokens = capture_tokens(&client)?; + let replacement_bundle = capture_auth_bundle(&client)?; let mut inner = self.inner.write().await; if !inner.active { return Err("Maple API authentication is no longer active".to_string()); } - let current_tokens = capture_tokens(&inner.credentials.client)?; - if inner.credentials.api_url == api_url && current_tokens == replacement_tokens { + let current_bundle = capture_auth_bundle(&inner.credentials.client)?; + if inner.credentials.api_url == api_url && current_bundle == replacement_bundle { return snapshot_from_inner(&self.user_id, &self.native_instance_id, &inner); } @@ -198,14 +192,44 @@ impl MapleApiSession { let client = Arc::clone(&inner.credentials.client); Ok(ClientSnapshot { generation: inner.credentials.generation, - tokens_before: capture_tokens(&client)?, + auth_bundle_before: capture_auth_bundle(&client)?, client, }) } async fn record_refresh(&self, snapshot: &ClientSnapshot) -> Result<(), String> { - let tokens_after = capture_tokens(&snapshot.client)?; - if tokens_after == snapshot.tokens_before { + self.record_auth_bundle_change(snapshot, capture_auth_bundle(&snapshot.client)) + .await + } + + async fn record_auth_bundle_change( + &self, + snapshot: &ClientSnapshot, + auth_bundle_after: Result, + ) -> Result<(), String> { + let auth_bundle_after = match auth_bundle_after { + Ok(bundle) => bundle, + Err(error) => { + let revision = { + let mut inner = self.inner.write().await; + if !inner.active + || inner.credentials.generation != snapshot.generation + || !Arc::ptr_eq(&inner.credentials.client, &snapshot.client) + { + return Ok(()); + } + inner.active = false; + inner.revision = inner + .revision + .checked_add(1) + .ok_or_else(|| "Maple API authentication revision exhausted".to_string())?; + inner.revision + }; + self.event_sink.auth_changed(&self.user_id, revision, false); + return Err(error); + } + }; + if auth_bundle_after == snapshot.auth_bundle_before { return Ok(()); } @@ -224,7 +248,7 @@ impl MapleApiSession { inner.revision }; - self.event_sink.auth_changed(&self.user_id, revision); + self.event_sink.auth_changed(&self.user_id, revision, true); Ok(()) } @@ -345,27 +369,96 @@ struct TestMapleApiAuthEventSink; #[cfg(test)] impl MapleApiAuthEventSink for TestMapleApiAuthEventSink { - fn auth_changed(&self, _user_id: &str, _revision: u64) {} + fn auth_changed(&self, _user_id: &str, _revision: u64, _authenticated: bool) {} } #[cfg(test)] pub(crate) fn test_maple_api_session(user_id: &str) -> Arc { let user_id = normalized_user_id(user_id).unwrap(); let account_scope = account_scope(&user_id).unwrap(); - let client = build_client("http://127.0.0.1:1", "test-access-token".to_string(), None).unwrap(); + let api_url = "http://127.0.0.1:1"; + let client = build_client( + api_url, + test_transport_v2_auth_bundle(api_url, &user_id, "test"), + ) + .unwrap(); Arc::new( MapleApiSession::new( Arc::new(TestMapleApiAuthEventSink), user_id, account_scope, "test-native-instance".to_string(), - "http://127.0.0.1:1".to_string(), + api_url.to_string(), client, ) .unwrap(), ) } +#[cfg(test)] +fn test_transport_v2_descriptors(user_id: &str, label: &str) -> (String, String) { + fn descriptor(audience: &str, kind: &str, subject: &str) -> String { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + + let claims = serde_json::json!({ + "iss": "urn:opensecret:transport-v2", + "aud": audience, + "tv": 2, + "tk": kind, + "pk": "user", + "sub": subject, + "exp": 2_000_000_000_u64, + }); + format!( + "e30.{}.test-signature", + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()) + ) + } + + let subject = format!("{user_id}|{label}"); + ( + descriptor( + "urn:opensecret:internal:transport-v2:user:access-descriptor", + "access_descriptor", + &subject, + ), + descriptor( + "urn:opensecret:internal:transport-v2:user:resumption", + "resumption", + &subject, + ), + ) +} + +#[cfg(test)] +fn test_transport_v2_auth_bundle(api_url: &str, user_id: &str, label: &str) -> String { + use base64::{ + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, + Engine as _, + }; + + #[derive(Serialize)] + struct TestAuthBundle<'a> { + version: u8, + api_origin: &'a str, + access_token: &'a str, + refresh_token: &'a str, + cache_namespace_root_base64: String, + } + + let (access_token, refresh_token) = test_transport_v2_descriptors(user_id, label); + URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&TestAuthBundle { + version: 2, + api_origin: api_url, + access_token: &access_token, + refresh_token: &refresh_token, + cache_namespace_root_base64: STANDARD.encode([0x42_u8; 32]), + }) + .unwrap(), + ) +} + fn map_operation_join_error(error: tokio::task::JoinError) -> opensecret::Error { log::warn!("Maple API operation task failed: {error}"); opensecret::Error::Other("Maple API operation failed".to_string()) @@ -421,31 +514,21 @@ fn snapshot_from_inner( native_instance_id: &str, inner: &MapleApiSessionInner, ) -> Result { - let tokens = capture_tokens(&inner.credentials.client)?; + let auth_bundle = capture_auth_bundle(&inner.credentials.client)?; Ok(MapleApiAuthSnapshot { user_id: user_id.to_string(), - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, + auth_bundle, native_instance_id: native_instance_id.to_string(), revision: inner.revision, }) } -fn capture_tokens(client: &OpenSecretClient) -> Result { - let tokens = client - .get_tokens() +fn capture_auth_bundle(client: &OpenSecretClient) -> Result { + client + .export_transport_v2_auth_bundle() .map_err(map_sdk_error)? - .ok_or_else(|| "Maple API access token is missing".to_string())?; - let access_token = (!tokens.access_token.trim().is_empty()) - .then_some(tokens.access_token) - .ok_or_else(|| "Maple API access token is missing".to_string())?; - let refresh_token = tokens - .refresh_token - .filter(|token| !token.trim().is_empty()); - Ok(TokenPair { - access_token, - refresh_token, - }) + .filter(|bundle| !bundle.trim().is_empty()) + .ok_or_else(|| "Maple API transport v2 authentication is missing".to_string()) } fn map_sdk_error(error: opensecret::Error) -> String { @@ -470,48 +553,17 @@ fn normalized_user_id(user_id: &str) -> Result { Ok(user_id) } -fn normalize_api_url(api_url: &str) -> Result { - let mut url = - reqwest::Url::parse(api_url.trim()).map_err(|_| "Maple API URL is invalid".to_string())?; - let host = url - .host_str() - .ok_or_else(|| "Maple API URL must include a host".to_string())?; - let loopback = host.eq_ignore_ascii_case("localhost") - || host - .parse::() - .is_ok_and(|ip| ip.is_loopback()); - if url.scheme() != "https" && !(url.scheme() == "http" && loopback) { - return Err("Maple API URL must use HTTPS or a loopback development address".to_string()); - } - if !url.username().is_empty() || url.password().is_some() { - return Err("Maple API URL must not contain credentials".to_string()); - } - if url.query().is_some() || url.fragment().is_some() { - return Err("Maple API URL must not contain a query or fragment".to_string()); - } - if url.path() != "/" && !url.path().is_empty() { - return Err("Maple API URL must not contain a path".to_string()); +fn build_client(api_url: &str, auth_bundle: String) -> Result, String> { + if auth_bundle.trim().is_empty() { + return Err("Maple API transport v2 authentication is missing".to_string()); } - url.set_path(""); - Ok(url.as_str().trim_end_matches('/').to_string()) -} - -fn build_client( - api_url: &str, - access_token: String, - refresh_token: Option, -) -> Result, String> { - if access_token.trim().is_empty() { - return Err("Maple API access token is missing".to_string()); - } - let refresh_token = refresh_token.filter(|token| !token.trim().is_empty()); let client = OpenSecretClient::new_with_pcr0_environment( api_url.to_string(), configured_pcr0_environment()?, ) .map_err(map_sdk_error)?; client - .set_tokens(access_token, refresh_token) + .import_transport_v2_auth_bundle(&auth_bundle) .map_err(map_sdk_error)?; Ok(Arc::new(client)) } @@ -592,7 +644,7 @@ impl MapleApiAuthState { let user_id = normalized_user_id(&request.user_id)?; let requested_scope = account_scope(&user_id)?; let api_url = normalize_api_url(&request.api_url)?; - let client = build_client(&api_url, request.access_token, request.refresh_token)?; + let client = build_client(&api_url, request.auth_bundle)?; tokio::time::timeout( CREDENTIAL_VALIDATION_TIMEOUT, self.credential_validator.validate(&client, &user_id), @@ -685,58 +737,64 @@ pub async fn maple_api_clear_auth( #[cfg(test)] mod tests { use super::*; - use axum::{ - extract::{Path, State}, - http::{header::AUTHORIZATION, HeaderMap, StatusCode}, - response::{IntoResponse, Response}, - routing::{get, post}, - Json, Router, - }; - use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; - use ciborium::value::Value as CborValue; - use goose_providers::{base::Provider, conversation::message::Message, model::ModelConfig}; - use opensecret::types::KeyExchangeRequest; + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use std::sync::Mutex as StdMutex; use tokio::sync::Notify; #[derive(Default)] struct RecordingEventSink { - events: StdMutex>, + events: StdMutex>, } impl MapleApiAuthEventSink for RecordingEventSink { - fn auth_changed(&self, user_id: &str, revision: u64) { - self.events - .lock() - .expect("event lock") - .push((user_id.to_string(), revision)); + fn auth_changed(&self, user_id: &str, revision: u64, authenticated: bool) { + self.events.lock().expect("event lock").push(( + user_id.to_string(), + revision, + authenticated, + )); } } - struct TokenPrefixCredentialValidator; + struct DescriptorCredentialValidator; #[async_trait::async_trait] - impl MapleApiCredentialValidator for TokenPrefixCredentialValidator { + impl MapleApiCredentialValidator for DescriptorCredentialValidator { async fn validate( &self, client: &OpenSecretClient, expected_user_id: &str, ) -> Result<(), String> { - let tokens = capture_tokens(client)?; - let actual_user_id = tokens + let tokens = client + .get_tokens() + .map_err(map_sdk_error)? + .ok_or_else(|| "test credential is missing".to_string())?; + let payload = tokens .access_token + .split('.') + .nth(1) + .ok_or_else(|| "test descriptor payload is missing".to_string())?; + let claims: serde_json::Value = serde_json::from_slice( + &URL_SAFE_NO_PAD + .decode(payload) + .map_err(|_| "test descriptor payload is invalid".to_string())?, + ) + .map_err(|_| "test descriptor claims are invalid".to_string())?; + let subject = claims + .get("sub") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "test descriptor subject is missing".to_string())?; + let (actual_user_id, label) = subject .split_once('|') - .map(|(user_id, _)| user_id) .ok_or_else(|| "test credential is missing its account prefix".to_string())?; if actual_user_id != expected_user_id { return Err("Maple API authentication belongs to a different account".to_string()); } - if tokens.access_token.ends_with("refresh-during-validation") { + if label == "refresh-during-validation" { + let (access, refresh) = + test_transport_v2_descriptors(expected_user_id, "validated"); client - .set_tokens( - format!("{expected_user_id}|validated-access"), - Some(format!("{expected_user_id}|validated-refresh")), - ) + .set_tokens(access, Some(refresh)) .map_err(map_sdk_error)?; } Ok(()) @@ -748,175 +806,6 @@ mod tests { release: Arc, } - #[derive(Clone)] - struct RefreshThenStallState { - key_pair: Arc, - session_key: [u8; 32], - session_id: String, - retry_started: Arc, - } - - struct RefreshThenStallFixture { - session: Arc, - sink: Arc, - retry_started: Arc, - server: tokio::task::JoinHandle<()>, - } - - fn mock_attestation_document(nonce: &str, server_public_key: &[u8; 32]) -> String { - let payload = CborValue::Map(vec![ - ( - CborValue::Text("public_key".to_string()), - CborValue::Bytes(server_public_key.to_vec()), - ), - ( - CborValue::Text("nonce".to_string()), - CborValue::Bytes(nonce.as_bytes().to_vec()), - ), - ]); - let mut payload_bytes = Vec::new(); - ciborium::ser::into_writer(&payload, &mut payload_bytes).unwrap(); - let cose_sign1 = CborValue::Array(vec![ - CborValue::Bytes(Vec::new()), - CborValue::Map(Vec::new()), - CborValue::Bytes(payload_bytes), - CborValue::Bytes(Vec::new()), - ]); - let mut cose_bytes = Vec::new(); - ciborium::ser::into_writer(&cose_sign1, &mut cose_bytes).unwrap(); - BASE64.encode(cose_bytes) - } - - async fn attestation_handler( - State(state): State, - Path(nonce): Path, - ) -> Json { - Json(serde_json::json!({ - "attestation_document": mock_attestation_document( - &nonce, - state.key_pair.public.as_bytes(), - ) - })) - } - - async fn key_exchange_handler( - State(state): State, - Json(request): Json, - ) -> Json { - let client_public_bytes = BASE64.decode(request.client_public_key).unwrap(); - let client_public_key = opensecret::crypto::PublicKey::from( - <[u8; 32]>::try_from(client_public_bytes.as_slice()).unwrap(), - ); - let shared_secret = - opensecret::crypto::derive_shared_secret(&state.key_pair.secret, &client_public_key); - let encrypted_session_key = BASE64.encode( - opensecret::crypto::encrypt_data(shared_secret.as_bytes(), &state.session_key).unwrap(), - ); - Json(serde_json::json!({ - "encrypted_session_key": encrypted_session_key, - "session_id": state.session_id, - })) - } - - async fn refresh_handler( - State(state): State, - ) -> Json { - let plaintext = serde_json::to_vec(&serde_json::json!({ - "access_token": "fresh_access", - "refresh_token": "fresh_refresh", - })) - .unwrap(); - let encrypted = opensecret::crypto::encrypt_data(&state.session_key, &plaintext).unwrap(); - Json(serde_json::json!({ "encrypted": BASE64.encode(encrypted) })) - } - - async fn refresh_then_stall_handler( - State(state): State, - headers: HeaderMap, - ) -> Response { - match headers - .get(AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - { - Some("Bearer expired_access") => { - (StatusCode::UNAUTHORIZED, "expired access token").into_response() - } - Some("Bearer fresh_access") => { - state.retry_started.notify_one(); - futures_util::future::pending::().await - } - _ => (StatusCode::FORBIDDEN, "unexpected credential").into_response(), - } - } - - async fn refresh_then_stall_fixture() -> RefreshThenStallFixture { - let key_pair = Arc::new(opensecret::crypto::generate_key_pair()); - let retry_started = Arc::new(Notify::new()); - let state = RefreshThenStallState { - key_pair, - session_key: [41; 32], - session_id: "00000000-0000-0000-0000-000000000041".to_string(), - retry_started: Arc::clone(&retry_started), - }; - let app = Router::new() - .route("/attestation/{nonce}", get(attestation_handler)) - .route("/key_exchange", post(key_exchange_handler)) - .route("/refresh", post(refresh_handler)) - .route("/v1/chat/completions", post(refresh_then_stall_handler)) - .route("/v1/web/search", post(refresh_then_stall_handler)) - .with_state(state); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let api_url = format!("http://{}", listener.local_addr().unwrap()); - let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - - let client = Arc::new(OpenSecretClient::new(api_url.clone()).unwrap()); - client - .set_tokens( - "expired_access".to_string(), - Some("old_refresh".to_string()), - ) - .unwrap(); - client.perform_attestation_handshake().await.unwrap(); - let sink = Arc::new(RecordingEventSink::default()); - let session = Arc::new( - MapleApiSession::new( - sink.clone(), - "user-a".to_string(), - account_scope("user-a").unwrap(), - "native-test-instance".to_string(), - api_url, - client, - ) - .unwrap(), - ); - RefreshThenStallFixture { - session, - sink, - retry_started, - server, - } - } - - async fn assert_refresh_reconciled(fixture: &RefreshThenStallFixture) { - let snapshot = tokio::time::timeout(std::time::Duration::from_secs(2), async { - loop { - let snapshot = fixture.session.auth_snapshot().await.unwrap(); - if snapshot.revision == 2 { - break snapshot; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("refreshed credentials should be reconciled"); - assert_eq!(snapshot.access_token, "fresh_access"); - assert_eq!(snapshot.refresh_token.as_deref(), Some("fresh_refresh")); - assert_eq!( - fixture.sink.events.lock().expect("event lock").as_slice(), - &[("user-a".to_string(), 2)] - ); - } - #[async_trait::async_trait] impl MapleApiCredentialValidator for BlockingCredentialValidator { async fn validate( @@ -926,22 +815,22 @@ mod tests { ) -> Result<(), String> { self.entered.notify_one(); self.release.notified().await; - TokenPrefixCredentialValidator + DescriptorCredentialValidator .validate(client, expected_user_id) .await } } fn test_state() -> MapleApiAuthState { - MapleApiAuthState::with_validator(Arc::new(TokenPrefixCredentialValidator)) + MapleApiAuthState::with_validator(Arc::new(DescriptorCredentialValidator)) } - fn auth_request(user_id: &str, access_token: &str) -> MapleApiAuthRequest { + fn auth_request(user_id: &str, label: &str) -> MapleApiAuthRequest { + let api_url = "https://enclave.trymaple.ai"; MapleApiAuthRequest { user_id: user_id.to_string(), - api_url: "https://enclave.trymaple.ai".to_string(), - access_token: format!("{user_id}|{access_token}"), - refresh_token: Some(format!("{user_id}|refresh-{access_token}")), + api_url: api_url.to_string(), + auth_bundle: test_transport_v2_auth_bundle(api_url, user_id, label), } } @@ -1001,7 +890,10 @@ mod tests { .await .unwrap(); assert_eq!(replaced.revision, 2); - assert_eq!(replaced.access_token, "user-a|access-two"); + assert_eq!( + replaced.auth_bundle, + auth_request("user-a", "access-two").auth_bundle + ); assert!(state .set_auth_with_sink(sink.clone(), auth_request("user-b", "other")) .await @@ -1034,15 +926,19 @@ mod tests { .set_auth_with_sink(sink.clone(), auth_request("user-a", "access-two")) .await .unwrap(); + let (late_access, late_refresh) = test_transport_v2_descriptors("user-a", "late"); old_snapshot .client - .set_tokens("late-access".to_string(), Some("late-refresh".to_string())) + .set_tokens(late_access, Some(late_refresh)) .unwrap(); session.record_refresh(&old_snapshot).await.unwrap(); let current = session.auth_snapshot().await.unwrap(); assert_eq!(current.revision, 2); - assert_eq!(current.access_token, "user-a|access-two"); + assert_eq!( + current.auth_bundle, + auth_request("user-a", "access-two").auth_bundle + ); assert!(sink.events.lock().expect("event lock").is_empty()); } @@ -1080,99 +976,63 @@ mod tests { let after = session.auth_snapshot().await.unwrap(); assert_eq!(after.revision, before.revision); - assert_eq!(after.access_token, before.access_token); - assert_eq!(after.refresh_token, before.refresh_token); + assert_eq!(after.auth_bundle, before.auth_bundle); } #[tokio::test] - async fn provider_cancellation_after_sdk_refresh_reconciles_rotated_credentials() { - let fixture = refresh_then_stall_fixture().await; - let provider = crate::agent::provider::MapleProvider::new(Arc::clone(&fixture.session)); - let cancellation = CancellationToken::new(); - let task_cancellation = cancellation.clone(); - let request = tokio::spawn(async move { - crate::agent::provider::with_run_cancellation( - task_cancellation, - provider.stream( - &ModelConfig::new("test-model"), - "system", - &[Message::user().with_text("classify this URL")], - &[], - ), - ) - .await - }); - - tokio::time::timeout( - std::time::Duration::from_secs(2), - fixture.retry_started.notified(), - ) - .await - .expect("refreshed inference retry should start"); - cancellation.cancel(); - let result = tokio::time::timeout(std::time::Duration::from_secs(2), request) + async fn current_generation_refresh_publishes_one_opaque_bundle_revision() { + let state = test_state(); + let sink = Arc::new(RecordingEventSink::default()); + state + .set_auth_with_sink(sink.clone(), auth_request("user-a", "access-one")) .await - .expect("provider cancellation should finish") .unwrap(); - assert!(matches!( - result, - Err(goose_providers::errors::ProviderError::ExecutionError(message)) - if message.contains("cancelled") - )); - assert_refresh_reconciled(&fixture).await; - fixture.server.abort(); - } + let session = state.session_for("user-a").await.unwrap(); + let snapshot = session.client_snapshot().await.unwrap(); + let (access, refresh) = test_transport_v2_descriptors("user-a", "resumed"); + snapshot.client.set_tokens(access, Some(refresh)).unwrap(); - #[tokio::test] - async fn dropped_web_call_after_sdk_refresh_still_reconciles_rotated_credentials() { - let fixture = refresh_then_stall_fixture().await; - let session = Arc::clone(&fixture.session); - let request = tokio::spawn(async move { - session - .web_search( - WebSearchRequest::new("maple privacy"), - CancellationToken::new(), - ) - .await - }); + session.record_refresh(&snapshot).await.unwrap(); - tokio::time::timeout( - std::time::Duration::from_secs(2), - fixture.retry_started.notified(), - ) - .await - .expect("refreshed web retry should start"); - request.abort(); - let _ = request.await; - assert_refresh_reconciled(&fixture).await; - fixture.server.abort(); + let current = session.auth_snapshot().await.unwrap(); + assert_eq!(current.revision, 2); + assert_eq!( + current.auth_bundle, + test_transport_v2_auth_bundle("https://enclave.trymaple.ai", "user-a", "resumed") + ); + assert_eq!( + sink.events.lock().expect("event lock").as_slice(), + &[("user-a".to_string(), 2, true)] + ); } #[tokio::test] - async fn dropped_classifier_provider_future_after_refresh_still_reconciles_credentials() { - let fixture = refresh_then_stall_fixture().await; - let provider = crate::agent::provider::MapleProvider::new(Arc::clone(&fixture.session)); - let request = tokio::spawn(async move { - provider - .complete( - &ModelConfig::new("llama3-3-70b"), - "classify web permission", - &[Message::user().with_text("untrusted classifier input")], - &[], - ) - .await - }); + async fn current_generation_bundle_loss_invalidates_and_notifies_once() { + let state = test_state(); + let sink = Arc::new(RecordingEventSink::default()); + state + .set_auth_with_sink(sink.clone(), auth_request("user-a", "access-one")) + .await + .unwrap(); + let session = state.session_for("user-a").await.unwrap(); + let snapshot = session.client_snapshot().await.unwrap(); - tokio::time::timeout( - std::time::Duration::from_secs(2), - fixture.retry_started.notified(), - ) - .await - .expect("refreshed classifier retry should start"); - request.abort(); - let _ = request.await; - assert_refresh_reconciled(&fixture).await; - fixture.server.abort(); + let error = session + .record_auth_bundle_change(&snapshot, Err("credentials rejected".to_string())) + .await + .expect_err("bundle loss must invalidate the matching native generation"); + + assert_eq!(error, "credentials rejected"); + assert!(session.auth_snapshot().await.is_err()); + assert_eq!( + sink.events.lock().expect("event lock").as_slice(), + &[("user-a".to_string(), 2, false)] + ); + session + .record_auth_bundle_change(&snapshot, Err("late duplicate".to_string())) + .await + .unwrap(); + assert_eq!(sink.events.lock().expect("event lock").len(), 1); } #[tokio::test] @@ -1185,12 +1045,12 @@ mod tests { .unwrap(); let mut wrong_account = auth_request("user-a", "access-two"); - wrong_account.access_token = "user-b|access-two".to_string(); - wrong_account.refresh_token = Some("user-b|refresh-access-two".to_string()); - let error = state - .set_auth_with_sink(sink, wrong_account) - .await - .expect_err("cross-account replacement must be rejected"); + wrong_account.auth_bundle = + test_transport_v2_auth_bundle("https://enclave.trymaple.ai", "user-b", "access-two"); + let error = match state.set_auth_with_sink(sink, wrong_account).await { + Ok(_) => panic!("cross-account replacement must be rejected"), + Err(error) => error, + }; assert!(error.contains("different account")); let current = state @@ -1200,12 +1060,15 @@ mod tests { .auth_snapshot() .await .unwrap(); - assert_eq!(current.access_token, "user-a|access-one"); + assert_eq!( + current.auth_bundle, + auth_request("user-a", "access-one").auth_bundle + ); assert_eq!(current.revision, 1); } #[tokio::test] - async fn validation_token_rotation_is_returned_to_the_browser_handshake() { + async fn validation_descriptor_rotation_is_returned_to_the_browser_handshake() { let state = test_state(); let snapshot = state .set_auth_with_sink( @@ -1215,10 +1078,9 @@ mod tests { .await .unwrap(); - assert_eq!(snapshot.access_token, "user-a|validated-access"); assert_eq!( - snapshot.refresh_token.as_deref(), - Some("user-a|validated-refresh") + snapshot.auth_bundle, + test_transport_v2_auth_bundle("https://enclave.trymaple.ai", "user-a", "validated") ); assert!(!snapshot.native_instance_id.is_empty()); } diff --git a/frontend/src-tauri/src/native_oauth.rs b/frontend/src-tauri/src/native_oauth.rs new file mode 100644 index 000000000..9e518393f --- /dev/null +++ b/frontend/src-tauri/src/native_oauth.rs @@ -0,0 +1,337 @@ +use crate::open_secret_config::{configured_pcr0_environment, normalize_api_url}; +use opensecret::{NativeOAuthHandoffGrant, OpenSecretClient}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{sync::Arc, time::Instant}; +use tauri::State; +use tokio::sync::Mutex; +use uuid::Uuid; + +const NATIVE_OAUTH_ATTEMPT_TTL: std::time::Duration = std::time::Duration::from_secs(15 * 60); +const NATIVE_OAUTH_NETWORK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BeginNativeOAuthRequest { + api_url: String, +} + +#[derive(Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NativeOAuthSession { + native_oauth_attempt: String, + session_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RedeemNativeOAuthRequest { + native_session_id: String, + handoff_grant: String, +} + +#[derive(Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NativeOAuthAuthentication { + user_id: String, + auth_bundle: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CancelNativeOAuthRequest { + native_oauth_attempt: String, +} + +struct PendingNativeOAuth { + attempt_id: Uuid, + started_at: Instant, + session_id: Uuid, + client: Arc, +} + +struct CompletedNativeOAuth { + attempt_id: Uuid, + session_id: Uuid, + grant_fingerprint: [u8; 32], + completed_at: Instant, + authentication: NativeOAuthAuthentication, +} + +enum NativeOAuthAttempt { + Pending(PendingNativeOAuth), + Completed(CompletedNativeOAuth), +} + +impl NativeOAuthAttempt { + fn attempt_id(&self) -> Uuid { + match self { + Self::Pending(attempt) => attempt.attempt_id, + Self::Completed(attempt) => attempt.attempt_id, + } + } + + fn is_expired(&self, now: Instant) -> bool { + let started_at = match self { + Self::Pending(attempt) => attempt.started_at, + Self::Completed(attempt) => attempt.completed_at, + }; + now.checked_duration_since(started_at) + .is_none_or(|age| age > NATIVE_OAUTH_ATTEMPT_TTL) + } +} + +pub struct NativeOAuthState { + attempt: Mutex>, +} + +impl NativeOAuthState { + pub fn new() -> Self { + Self { + attempt: Mutex::new(None), + } + } + + async fn begin(&self, request: BeginNativeOAuthRequest) -> Result { + // Serialize begin/redeem/cancel so a slower handshake can never publish + // over a newer native login attempt. + let mut current = self.attempt.lock().await; + let api_url = normalize_api_url(&request.api_url)?; + let client = Arc::new( + OpenSecretClient::new_with_pcr0_environment(api_url, configured_pcr0_environment()?) + .map_err(|error| map_sdk_error("prepare", &error))?, + ); + let prepared = tokio::time::timeout( + NATIVE_OAUTH_NETWORK_TIMEOUT, + client.prepare_native_oauth_session(), + ) + .await + .map_err(|_| "Secure authentication setup timed out; please try again".to_string())? + .map_err(|error| map_sdk_error("prepare", &error))?; + let attempt_id = Uuid::new_v4(); + let response = NativeOAuthSession { + native_oauth_attempt: attempt_id.to_string(), + session_id: prepared.session_id.to_string(), + }; + *current = Some(NativeOAuthAttempt::Pending(PendingNativeOAuth { + attempt_id, + started_at: Instant::now(), + session_id: prepared.session_id, + client, + })); + Ok(response) + } + + async fn redeem( + &self, + request: RedeemNativeOAuthRequest, + ) -> Result { + let expected_session_id = parse_canonical_uuid(&request.native_session_id) + .map_err(|_| "Native authentication session is invalid; restart sign-in".to_string())?; + let grant_fingerprint: [u8; 32] = Sha256::digest(request.handoff_grant.as_bytes()).into(); + let grant = NativeOAuthHandoffGrant::new(request.handoff_grant) + .map_err(|error| map_sdk_error("validate", &error))?; + let mut current = self.attempt.lock().await; + if current + .as_ref() + .is_some_and(|attempt| attempt.is_expired(Instant::now())) + { + *current = None; + return Err("Native authentication expired; restart sign-in".to_string()); + } + + match current.as_ref() { + Some(NativeOAuthAttempt::Completed(completed)) + if completed.session_id == expected_session_id + && completed.grant_fingerprint == grant_fingerprint => + { + return Ok(completed.authentication.clone()); + } + Some(NativeOAuthAttempt::Completed(_)) => { + return Err( + "Native authentication callback does not match the completed sign-in" + .to_string(), + ); + } + None => { + return Err("Native authentication is not pending; restart sign-in".to_string()); + } + Some(NativeOAuthAttempt::Pending(pending)) + if pending.session_id != expected_session_id => + { + return Err( + "Native authentication callback does not match the pending sign-in".to_string(), + ); + } + Some(NativeOAuthAttempt::Pending(_)) => {} + } + + let pending = match current.take() { + Some(NativeOAuthAttempt::Pending(pending)) => pending, + _ => unreachable!("pending attempt checked above"), + }; + let attempt_id = pending.attempt_id; + let login = match tokio::time::timeout( + NATIVE_OAUTH_NETWORK_TIMEOUT, + pending.client.redeem_native_oauth_handoff( + pending.session_id, + pending.attempt_id, + grant, + ), + ) + .await + { + Ok(Ok(login)) => login, + Ok(Err(error)) => return Err(map_sdk_error("redeem", &error)), + Err(_) => { + return Err( + "Secure authentication completion timed out; restart sign-in".to_string(), + ); + } + }; + let auth_bundle = pending + .client + .export_transport_v2_auth_bundle() + .map_err(|error| map_sdk_error("export", &error))? + .ok_or_else(|| { + "Secure authentication returned no resumable credentials; restart sign-in" + .to_string() + })?; + let authentication = NativeOAuthAuthentication { + user_id: login.id.to_string(), + auth_bundle, + }; + *current = Some(NativeOAuthAttempt::Completed(CompletedNativeOAuth { + attempt_id, + session_id: pending.session_id, + grant_fingerprint, + completed_at: Instant::now(), + authentication: authentication.clone(), + })); + Ok(authentication) + } + + async fn cancel(&self, request: CancelNativeOAuthRequest) -> Result<(), String> { + let attempt_id = parse_canonical_uuid(&request.native_oauth_attempt) + .map_err(|_| "Native authentication state is invalid".to_string())?; + let mut current = self.attempt.lock().await; + if current + .as_ref() + .is_some_and(|attempt| attempt.attempt_id() == attempt_id) + { + *current = None; + } + Ok(()) + } +} + +fn parse_canonical_uuid(value: &str) -> Result { + let parsed = Uuid::parse_str(value).map_err(|_| ())?; + if parsed.is_nil() || parsed.to_string() != value { + return Err(()); + } + Ok(parsed) +} + +fn map_sdk_error(action: &'static str, error: &opensecret::Error) -> String { + let category = match error { + opensecret::Error::Http(_) => "http", + opensecret::Error::Serialization(_) => "serialization", + opensecret::Error::Cbor(_) => "cbor", + opensecret::Error::Crypto(_) => "crypto", + opensecret::Error::AttestationVerificationFailed(_) => "attestation", + opensecret::Error::Session(_) => "session", + opensecret::Error::KeyExchange(_) => "key_exchange", + opensecret::Error::Encryption(_) => "encryption", + opensecret::Error::Decryption(_) => "decryption", + opensecret::Error::Authentication(_) => "authentication", + opensecret::Error::InvalidResponse(_) => "invalid_response", + opensecret::Error::Api { .. } => "api", + opensecret::Error::Configuration(_) => "configuration", + opensecret::Error::Io(_) => "io", + opensecret::Error::Utf8(_) => "utf8", + opensecret::Error::Base64Decode(_) => "base64", + opensecret::Error::Other(_) => "other", + }; + log::warn!("Native OAuth {action} failed ({category})"); + "Secure authentication failed; restart sign-in".to_string() +} + +#[tauri::command] +pub async fn native_oauth_begin( + state: State<'_, NativeOAuthState>, + request: BeginNativeOAuthRequest, +) -> Result { + state.begin(request).await +} + +#[tauri::command] +pub async fn native_oauth_redeem( + state: State<'_, NativeOAuthState>, + request: RedeemNativeOAuthRequest, +) -> Result { + state.redeem(request).await +} + +#[tauri::command] +pub async fn native_oauth_cancel( + state: State<'_, NativeOAuthState>, + request: CancelNativeOAuthRequest, +) -> Result<(), String> { + state.cancel(request).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_identifiers_require_lowercase_canonical_uuid_text() { + let id = Uuid::new_v4(); + assert_eq!(parse_canonical_uuid(&id.to_string()), Ok(id)); + assert!(parse_canonical_uuid(&id.to_string().to_uppercase()).is_err()); + assert!(parse_canonical_uuid(&format!("{{{id}}}")).is_err()); + assert!(parse_canonical_uuid(&Uuid::nil().to_string()).is_err()); + assert!(parse_canonical_uuid("not-a-uuid").is_err()); + } + + #[test] + fn sdk_errors_are_reduced_to_safe_native_messages() { + let error = opensecret::Error::Authentication("sensitive upstream detail".to_string()); + let message = map_sdk_error("test", &error); + assert!(!message.contains("sensitive")); + assert_eq!(message, "Secure authentication failed; restart sign-in"); + } + + #[tokio::test] + async fn stale_public_session_does_not_consume_the_pending_attempt() { + let attempt_id = Uuid::new_v4(); + let session_id = Uuid::new_v4(); + let state = NativeOAuthState { + attempt: Mutex::new(Some(NativeOAuthAttempt::Pending(PendingNativeOAuth { + attempt_id, + started_at: Instant::now(), + session_id, + client: Arc::new( + OpenSecretClient::new("http://127.0.0.1:9") + .expect("test client configuration must be valid"), + ), + }))), + }; + + let result = state + .redeem(RedeemNativeOAuthRequest { + native_session_id: Uuid::new_v4().to_string(), + handoff_grant: "header.payload.signature".to_string(), + }) + .await; + assert!(result.is_err()); + + let current = state.attempt.lock().await; + let Some(NativeOAuthAttempt::Pending(pending)) = current.as_ref() else { + panic!("stale callback must preserve the pending native attempt"); + }; + assert_eq!(pending.attempt_id, attempt_id); + assert_eq!(pending.session_id, session_id); + } +} diff --git a/frontend/src-tauri/src/open_secret_config.rs b/frontend/src-tauri/src/open_secret_config.rs index affdb765f..28cdfee84 100644 --- a/frontend/src-tauri/src/open_secret_config.rs +++ b/frontend/src-tauri/src/open_secret_config.rs @@ -1,4 +1,30 @@ -use maple_proxy::Pcr0Environment; +use opensecret::Pcr0Environment; + +pub(crate) fn normalize_api_url(api_url: &str) -> Result { + let mut url = + reqwest::Url::parse(api_url.trim()).map_err(|_| "Maple API URL is invalid".to_string())?; + let host = url + .host_str() + .ok_or_else(|| "Maple API URL must include a host".to_string())?; + let loopback = host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()); + if url.scheme() != "https" && !(url.scheme() == "http" && loopback) { + return Err("Maple API URL must use HTTPS or a loopback development address".to_string()); + } + if !url.username().is_empty() || url.password().is_some() { + return Err("Maple API URL must not contain credentials".to_string()); + } + if url.query().is_some() || url.fragment().is_some() { + return Err("Maple API URL must not contain a query or fragment".to_string()); + } + if url.path() != "/" && !url.path().is_empty() { + return Err("Maple API URL must not contain a path".to_string()); + } + url.set_path(""); + Ok(url.as_str().trim_end_matches('/').to_string()) +} pub(crate) fn parse_pcr0_environment(value: Option<&str>) -> Result { match value { @@ -36,4 +62,23 @@ mod tests { assert!(parse_pcr0_environment(Some(invalid)).is_err()); } } + + #[test] + fn api_url_normalization_requires_https_or_exact_loopback() { + assert_eq!( + normalize_api_url("https://enclave.trymaple.ai/").unwrap(), + "https://enclave.trymaple.ai" + ); + assert_eq!( + normalize_api_url("http://127.0.0.1:31745").unwrap(), + "http://127.0.0.1:31745" + ); + assert_eq!( + normalize_api_url("http://localhost:31745/").unwrap(), + "http://localhost:31745" + ); + assert!(normalize_api_url("http://enclave.trymaple.ai").is_err()); + assert!(normalize_api_url("https://user:pass@example.com").is_err()); + assert!(normalize_api_url("https://example.com/v1").is_err()); + } } diff --git a/frontend/src-tauri/src/proxy.rs b/frontend/src-tauri/src/proxy.rs index 6b83413dc..3ddf9eafc 100644 --- a/frontend/src-tauri/src/proxy.rs +++ b/frontend/src-tauri/src/proxy.rs @@ -7,11 +7,9 @@ use axum::{ response::{IntoResponse, Response}, Router, }; -use maple_proxy::{create_app, Config}; +use maple_proxy::{create_app, Config, TransportV2CacheNamespaceRoot}; use serde::{Deserialize, Serialize}; -#[cfg(any(target_os = "macos", target_os = "linux"))] -use std::path::Path; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; #[cfg(any(target_os = "macos", target_os = "linux"))] use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; @@ -25,7 +23,7 @@ use tower_http::cors::{AllowHeaders, Any, CorsLayer}; #[cfg(any(target_os = "macos", target_os = "linux"))] const MAPLE_APP_IDENTIFIER: &str = "cloud.opensecret.maple"; #[cfg(any(target_os = "macos", target_os = "linux"))] -static LEGACY_CONFIG_MIGRATION_COUNTER: AtomicU64 = AtomicU64::new(0); +static PROXY_CONFIG_FILE_COUNTER: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProxyConfig { @@ -69,6 +67,7 @@ pub struct ProxyStatus { pub struct ProxyState { handle: Arc>>>, config: Arc>, + cache_namespace_root: Arc>>, running: Arc>, lifecycle: Arc>, } @@ -78,6 +77,7 @@ impl ProxyState { Self { handle: Arc::new(Mutex::new(None)), config: Arc::new(Mutex::new(ProxyConfig::default())), + cache_namespace_root: Arc::new(Mutex::new(None)), running: Arc::new(Mutex::new(false)), lifecycle: Arc::new(Mutex::new(())), } @@ -92,15 +92,52 @@ impl ProxyState { } } -// On Windows the proxy config lives in the roaming %APPDATA% profile, so a -// plaintext api_key could sync across machines in a domain/AAD environment. -// Store it in Windows Credential Manager instead and keep it out of the JSON. -// The Tauri identifier scopes both the config directory and credential entry, -// so managed workspace builds cannot read or overwrite production's key. The -// production identifier remains the legacy service name, requiring no migration. -// macOS/Linux keep their local plaintext-with-0o600 behavior unchanged. +// On Windows the proxy config lives in roaming %APPDATA%, so secrets in that +// JSON could sync across machines in a domain/AAD environment. Keep the +// existing API-key Credential Manager behavior, but always store the +// transport-v2 cache root in identifier-scoped LocalAppData. The keyring crate +// uses enterprise-persistent WinCred entries, which are not device-local. The +// cache root is therefore never written to Credential Manager or roaming JSON. +// macOS/Linux atomically persist both secrets in owner-only (0o600) config. #[cfg(target_os = "windows")] const KEYRING_USER: &str = "proxy_api_key"; +#[cfg(target_os = "windows")] +const CACHE_ROOT_DEVICE_FILE: &str = "proxy_transport_v2_cache_namespace_root"; + +/// Atomically replace a secret-bearing file. `NamedTempFile` creates the +/// temporary file with owner-only mode on Unix; set it explicitly before the +/// first secret byte is written and persist it within the same directory. +async fn atomic_write_secret_file(path: &Path, contents: Vec) -> Result<()> { + let path = path.to_path_buf(); + tokio::task::spawn_blocking(move || { + use std::io::Write as _; + + let parent = path + .parent() + .ok_or_else(|| anyhow!("Secret file path has no parent directory"))?; + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + temporary + .as_file() + .set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + + temporary.write_all(&contents)?; + temporary.as_file().sync_all()?; + let persisted = temporary.persist(&path).map_err(|error| error.error)?; + persisted.sync_all()?; + + #[cfg(unix)] + std::fs::File::open(parent)?.sync_all()?; + + Ok::<(), anyhow::Error>(()) + }) + .await + .map_err(|error| anyhow!("Secret file write task failed: {error}"))? +} /// Persist the API key in Windows Credential Manager. An empty key clears the /// entry. Returns `Ok(true)` when the key was stored (or cleared), `Ok(false)` @@ -170,6 +207,59 @@ fn load_api_key(app_handle: &AppHandle) -> Result> { } } +/// Keep the stable cache root in identifier-scoped LocalAppData rather than +/// roaming AppData or enterprise-persistent Windows Credential Manager. +#[cfg(target_os = "windows")] +async fn cache_namespace_root_device_path(app_handle: &AppHandle) -> Result { + let directory = app_handle + .path() + .app_local_data_dir() + .map_err(|error| anyhow!("Failed to resolve app-local data directory: {error}"))?; + tokio::fs::create_dir_all(&directory).await?; + Ok(directory.join(CACHE_ROOT_DEVICE_FILE)) +} + +#[cfg(target_os = "windows")] +async fn load_device_cache_namespace_root( + app_handle: &AppHandle, +) -> Result> { + let path = cache_namespace_root_device_path(app_handle).await?; + let encoded = match tokio::fs::read_to_string(&path).await { + Ok(encoded) => encoded, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + TransportV2CacheNamespaceRoot::from_base64(&encoded) + .map(Some) + .map_err(|_| anyhow!("Invalid device-local proxy transport cache root")) +} + +#[cfg(target_os = "windows")] +async fn persist_windows_cache_namespace_root( + app_handle: &AppHandle, + root: &TransportV2CacheNamespaceRoot, +) -> Result<()> { + let path = cache_namespace_root_device_path(app_handle).await?; + atomic_write_secret_file(&path, root.to_base64().into_bytes()).await +} + +#[cfg(target_os = "windows")] +async fn load_windows_cache_namespace_root( + app_handle: &AppHandle, + legacy_roaming_value: Option<&str>, +) -> Result<(Option, bool)> { + let has_legacy_roaming_value = legacy_roaming_value.is_some(); + if let Some(root) = load_device_cache_namespace_root(app_handle).await? { + return Ok((Some(root), has_legacy_roaming_value)); + } + + let root = legacy_roaming_value + .map(TransportV2CacheNamespaceRoot::from_base64) + .transpose() + .map_err(|_| anyhow!("Invalid legacy proxy transport cache root"))?; + Ok((root, has_legacy_roaming_value)) +} + #[tauri::command] pub async fn start_proxy( app_handle: AppHandle, @@ -207,7 +297,12 @@ async fn start_proxy_inner( .clone() .unwrap_or_else(|| "https://enclave.trymaple.ai".to_string()); - let proxy_config = build_proxy_server_config(&config, backend_url)?; + let cache_namespace_root = cache_namespace_root_for_app(&app_handle, state) + .await + .map_err(|error| format!("Failed to load proxy transport cache state: {error}"))?; + + let proxy_config = + build_proxy_server_config(&config, backend_url, cache_namespace_root.clone())?; // Try to bind to the address first to check if port is available let addr = proxy_config @@ -227,7 +322,7 @@ async fn start_proxy_inner( // Starting successfully means the exact credential/configuration is also // durable. In particular, do not hide Credential Manager or disk failures // behind a running in-memory proxy that will change after restart. - save_proxy_config(&app_handle, &config) + save_proxy_config(&app_handle, &config, &cache_namespace_root) .await .map_err(|error| format!("Failed to save proxy config: {error}"))?; *state.config.lock().await = config.clone(); @@ -259,9 +354,14 @@ async fn start_proxy_inner( }) } -fn build_proxy_server_config(config: &ProxyConfig, backend_url: String) -> Result { +fn build_proxy_server_config( + config: &ProxyConfig, + backend_url: String, + cache_namespace_root: TransportV2CacheNamespaceRoot, +) -> Result { let proxy_config = Config::new(config.host.clone(), config.port, backend_url) .with_pcr0_environment(configured_pcr0_environment()?) + .with_cache_namespace_root(cache_namespace_root) .with_debug(false) // Maple owns the browser boundary below so it can both list the // non-wildcard Authorization header and reject browser origins when @@ -357,6 +457,9 @@ pub async fn load_proxy_config( state: State<'_, ProxyState>, ) -> Result { let _lifecycle_guard = state.lifecycle.lock().await; + cache_namespace_root_for_app(&app_handle, &state) + .await + .map_err(|e| format!("Failed to load proxy transport cache state: {e}"))?; load_saved_proxy_config(&app_handle) .await .map_err(|e| format!("Failed to load proxy config: {e}")) @@ -369,7 +472,10 @@ pub async fn save_proxy_settings( config: ProxyConfig, ) -> Result<(), String> { let _lifecycle_guard = state.lifecycle.lock().await; - save_proxy_config(&app_handle, &config) + let cache_namespace_root = cache_namespace_root_for_app(&app_handle, &state) + .await + .map_err(|e| format!("Failed to load proxy transport cache state: {e}"))?; + save_proxy_config(&app_handle, &config, &cache_namespace_root) .await .map_err(|e| format!("Failed to save proxy config: {e}")) } @@ -382,6 +488,10 @@ pub async fn stop_and_reset_proxy( let _lifecycle_guard = state.lifecycle.lock().await; stop_proxy_inner(&state).await?; + let cache_namespace_root = cache_namespace_root_for_app(&app_handle, &state) + .await + .map_err(|error| format!("Failed to load proxy transport cache state: {error}"))?; + // Clear account-bound state without discarding app/workspace routing such // as the managed proxy port or backend URL. let mut config = match load_saved_proxy_config(&app_handle).await { @@ -391,7 +501,7 @@ pub async fn stop_and_reset_proxy( config.api_key.clear(); config.enabled = false; config.auto_start = false; - save_proxy_config(&app_handle, &config) + save_proxy_config(&app_handle, &config, &cache_namespace_root) .await .map_err(|error| format!("Failed to reset proxy config: {error}"))?; @@ -436,6 +546,10 @@ mod tests { use super::*; use axum::http::header::CONTENT_TYPE; + fn test_cache_namespace_root() -> TransportV2CacheNamespaceRoot { + TransportV2CacheNamespaceRoot::from_bytes([0x42; 32]) + } + #[test] fn new_and_legacy_unspecified_configs_disable_cors() { assert!(!ProxyConfig::default().enable_cors); @@ -451,6 +565,28 @@ mod tests { assert!(!config.enable_cors); } + #[test] + fn renderer_config_never_serializes_the_transport_cache_root() { + let public_json = serde_json::to_value(ProxyConfig::default()).unwrap(); + assert!(public_json + .get("transport_v2_cache_namespace_root") + .is_none()); + + let persisted_json = serde_json::to_value(persisted_proxy_config( + &ProxyConfig::default(), + &test_cache_namespace_root(), + )) + .unwrap(); + #[cfg(not(target_os = "windows"))] + assert!(persisted_json + .get("transport_v2_cache_namespace_root") + .is_some()); + #[cfg(target_os = "windows")] + assert!(persisted_json + .get("transport_v2_cache_namespace_root") + .is_none()); + } + #[test] fn explicit_cors_keeps_browser_access_but_removes_saved_credential_fallback() { let config = ProxyConfig { @@ -459,11 +595,19 @@ mod tests { ..ProxyConfig::default() }; - let server_config = - build_proxy_server_config(&config, "https://example.invalid".to_string()).unwrap(); + let server_config = build_proxy_server_config( + &config, + "https://example.invalid".to_string(), + test_cache_namespace_root(), + ) + .unwrap(); assert!(!server_config.enable_cors); assert!(server_config.default_api_key.is_none()); + assert_eq!( + server_config.cache_namespace_root, + Some(test_cache_namespace_root()) + ); assert_eq!( server_config.pcr0_environment, configured_pcr0_environment().unwrap() @@ -477,8 +621,12 @@ mod tests { ..ProxyConfig::default() }; - let server_config = - build_proxy_server_config(&config, "https://example.invalid".to_string()).unwrap(); + let server_config = build_proxy_server_config( + &config, + "https://example.invalid".to_string(), + test_cache_namespace_root(), + ) + .unwrap(); assert!(!server_config.enable_cors); assert_eq!(server_config.default_api_key.as_deref(), Some("saved-key")); @@ -497,8 +645,12 @@ mod tests { api_key: "saved-key".to_string(), ..ProxyConfig::default() }; - let server_config = - build_proxy_server_config(&config, "https://example.invalid".to_string()).unwrap(); + let server_config = build_proxy_server_config( + &config, + "https://example.invalid".to_string(), + test_cache_namespace_root(), + ) + .unwrap(); let app = apply_proxy_access_policy(server_config, config.enable_cors); let (base_url, server) = serve_test_app(app).await; @@ -521,8 +673,12 @@ mod tests { api_key: "saved-key".to_string(), ..ProxyConfig::default() }; - let server_config = - build_proxy_server_config(&config, "https://example.invalid".to_string()).unwrap(); + let server_config = build_proxy_server_config( + &config, + "https://example.invalid".to_string(), + test_cache_namespace_root(), + ) + .unwrap(); let app = apply_proxy_access_policy(server_config, config.enable_cors); let (base_url, server) = serve_test_app(app).await; @@ -544,8 +700,12 @@ mod tests { enable_cors: true, ..ProxyConfig::default() }; - let server_config = - build_proxy_server_config(&config, "https://example.invalid".to_string()).unwrap(); + let server_config = build_proxy_server_config( + &config, + "https://example.invalid".to_string(), + test_cache_namespace_root(), + ) + .unwrap(); let app = apply_proxy_access_policy(server_config, config.enable_cors); let (base_url, server) = serve_test_app(app).await; @@ -616,13 +776,55 @@ mod tests { #[cfg(any(target_os = "macos", target_os = "linux"))] fn config_migration_test_dir() -> PathBuf { - let counter = LEGACY_CONFIG_MIGRATION_COUNTER.fetch_add(1, Ordering::Relaxed); + let counter = PROXY_CONFIG_FILE_COUNTER.fetch_add(1, Ordering::Relaxed); std::env::temp_dir().join(format!( "maple-proxy-config-migration-{}-{counter}", std::process::id() )) } + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[tokio::test] + async fn atomic_secret_write_is_owner_only_from_creation_and_replaces_contents() { + use std::os::unix::fs::PermissionsExt; + + let root = config_migration_test_dir(); + tokio::fs::create_dir_all(&root).await.unwrap(); + let path = root.join("proxy_config.json"); + + atomic_write_secret_file(&path, b"first-secret".to_vec()) + .await + .unwrap(); + assert_eq!(tokio::fs::read(&path).await.unwrap(), b"first-secret"); + assert_eq!( + tokio::fs::metadata(&path) + .await + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + + atomic_write_secret_file(&path, b"replacement-secret".to_vec()) + .await + .unwrap(); + assert_eq!(tokio::fs::read(&path).await.unwrap(), b"replacement-secret"); + assert_eq!( + tokio::fs::metadata(&path) + .await + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + + let entries = std::fs::read_dir(&root).unwrap().count(); + assert_eq!(entries, 1, "atomic write left a temporary file behind"); + tokio::fs::remove_dir_all(root).await.unwrap(); + } + #[cfg(any(target_os = "macos", target_os = "linux"))] #[tokio::test] async fn migrates_legacy_config_once_and_scrubs_source() { @@ -920,7 +1122,7 @@ async fn migrate_legacy_proxy_config(legacy_path: &Path, target_path: &Path) -> return Ok(()); } - let counter = LEGACY_CONFIG_MIGRATION_COUNTER.fetch_add(1, Ordering::Relaxed); + let counter = PROXY_CONFIG_FILE_COUNTER.fetch_add(1, Ordering::Relaxed); let temp_name = format!(".proxy_config.migrate-{}-{counter}.tmp", std::process::id()); let temp_path = target_path.with_file_name(temp_name); @@ -1007,7 +1209,7 @@ async fn scrub_legacy_proxy_config( config.enabled = false; config.auto_start = false; - let counter = LEGACY_CONFIG_MIGRATION_COUNTER.fetch_add(1, Ordering::Relaxed); + let counter = PROXY_CONFIG_FILE_COUNTER.fetch_add(1, Ordering::Relaxed); let temp_name = format!(".proxy_config.scrub-{}-{counter}.tmp", std::process::id()); let temp_path = path.with_file_name(temp_name); let rewrite = async { @@ -1031,20 +1233,104 @@ async fn scrub_legacy_proxy_config( rewrite } -async fn save_proxy_config(app_handle: &AppHandle, config: &ProxyConfig) -> Result<()> { +#[derive(Clone, Serialize, Deserialize)] +struct PersistedProxyConfig { + #[serde(flatten)] + config: ProxyConfig, + #[serde(default, skip_serializing_if = "Option::is_none")] + transport_v2_cache_namespace_root: Option, +} + +struct LoadedProxyConfig { + config: ProxyConfig, + cache_namespace_root: Option, + cache_namespace_root_needs_persistence: bool, + #[cfg(target_os = "windows")] + roaming_config: ProxyConfig, +} + +fn persisted_proxy_config( + config: &ProxyConfig, + cache_namespace_root: &TransportV2CacheNamespaceRoot, +) -> PersistedProxyConfig { + #[cfg(target_os = "windows")] + let _ = cache_namespace_root; + PersistedProxyConfig { + config: config.clone(), + #[cfg(not(target_os = "windows"))] + transport_v2_cache_namespace_root: Some(cache_namespace_root.to_base64()), + #[cfg(target_os = "windows")] + transport_v2_cache_namespace_root: None, + } +} + +async fn cache_namespace_root_for_app( + app_handle: &AppHandle, + state: &ProxyState, +) -> Result { + if let Some(root) = state.cache_namespace_root.lock().await.as_ref().cloned() { + return Ok(root); + } + + let loaded = load_saved_proxy_state(app_handle).await?; + let (root, generated) = match loaded.cache_namespace_root { + Some(root) => (root, false), + None => (TransportV2CacheNamespaceRoot::generate()?, true), + }; + + if generated || loaded.cache_namespace_root_needs_persistence { + // Persist before admitting proxy traffic. A process-only root would + // silently destroy cache continuity on the next app restart. + #[cfg(target_os = "windows")] + { + persist_windows_cache_namespace_root(app_handle, &root).await?; + if loaded.cache_namespace_root_needs_persistence { + remove_windows_roaming_cache_namespace_root(app_handle, &loaded.roaming_config) + .await?; + } + } + #[cfg(not(target_os = "windows"))] + save_proxy_config(app_handle, &loaded.config, &root).await?; + } + + *state.cache_namespace_root.lock().await = Some(root.clone()); + Ok(root) +} + +#[cfg(target_os = "windows")] +async fn remove_windows_roaming_cache_namespace_root( + app_handle: &AppHandle, + roaming_config: &ProxyConfig, +) -> Result<()> { let path = get_config_path(app_handle).await?; + let persisted = PersistedProxyConfig { + config: roaming_config.clone(), + transport_v2_cache_namespace_root: None, + }; + atomic_write_secret_file(&path, serde_json::to_vec_pretty(&persisted)?).await +} - // On Windows, move the API key into Credential Manager and scrub it from - // the JSON (the config dir is the roaming profile). Other platforms retain - // the existing owner-only JSON behavior. +async fn save_proxy_config( + app_handle: &AppHandle, + config: &ProxyConfig, + cache_namespace_root: &TransportV2CacheNamespaceRoot, +) -> Result<()> { + let path = get_config_path(app_handle).await?; + let persisted = persisted_proxy_config(config, cache_namespace_root); #[cfg(target_os = "windows")] - let json = { - let scrubbed = match store_api_key(app_handle, &config.api_key) { + let mut persisted = persisted; + + // On Windows, keep the API key's existing Credential Manager behavior and + // always keep the cache root out of roaming JSON. Root persistence is + // independent and already completed before this save path is entered. + #[cfg(target_os = "windows")] + { + persisted.config = match store_api_key(app_handle, &config.api_key) { Ok(true) => ProxyConfig { api_key: String::new(), - ..config.clone() + ..persisted.config }, - Ok(false) => config.clone(), + Ok(false) => persisted.config, // Clearing the key failed: don't scrub the JSON and report success, // since the stale credential survives and would be resurrected on // the next load. Propagate so the failure is visible. @@ -1053,49 +1339,69 @@ async fn save_proxy_config(app_handle: &AppHandle, config: &ProxyConfig) -> Resu // in plaintext JSON so it isn't lost. Err(e) => { log::warn!("{e}"); - config.clone() + persisted.config } }; - serde_json::to_string_pretty(&scrubbed)? - }; - #[cfg(not(target_os = "windows"))] - let json = serde_json::to_string_pretty(config)?; - - // Write the config file - tokio::fs::write(&path, json).await?; - - // Set restrictive permissions on Unix systems (owner read/write only) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o600); - tokio::fs::set_permissions(&path, perms).await?; } - Ok(()) + let json = serde_json::to_string_pretty(&persisted)?; + atomic_write_secret_file(&path, json.into_bytes()).await } pub async fn load_saved_proxy_config(app_handle: &AppHandle) -> Result { - let path = get_config_path(app_handle).await?; + Ok(load_saved_proxy_state(app_handle).await?.config) +} - if !path.exists() { - return Ok(ProxyConfig::default()); - } +async fn load_saved_proxy_state(app_handle: &AppHandle) -> Result { + let path = get_config_path(app_handle).await?; - let json = tokio::fs::read_to_string(path).await?; - #[cfg_attr(not(target_os = "windows"), allow(unused_mut))] - let mut config: ProxyConfig = serde_json::from_str(&json)?; + let persisted = if path.exists() { + let json = tokio::fs::read_to_string(path).await?; + serde_json::from_str::(&json)? + } else { + PersistedProxyConfig { + config: ProxyConfig::default(), + transport_v2_cache_namespace_root: None, + } + }; + #[cfg(target_os = "windows")] + let mut persisted = persisted; + #[cfg(target_os = "windows")] + let roaming_config = persisted.config.clone(); - // On Windows, prefer the API key from Credential Manager; fall back to any - // plaintext value still in the JSON if it's unavailable. + // On Windows, prefer the API key from Credential Manager. The cache root + // lives in identifier-scoped LocalAppData; a legacy roaming JSON value is + // migrated there and scrubbed on load. #[cfg(target_os = "windows")] if let Some(key) = load_api_key(app_handle)? { if !key.is_empty() { - config.api_key = key; + persisted.config.api_key = key; } } - - Ok(config) + #[cfg(target_os = "windows")] + let (cache_namespace_root, cache_namespace_root_needs_persistence) = + load_windows_cache_namespace_root( + app_handle, + persisted.transport_v2_cache_namespace_root.as_deref(), + ) + .await?; + #[cfg(not(target_os = "windows"))] + let cache_namespace_root = persisted + .transport_v2_cache_namespace_root + .as_deref() + .map(TransportV2CacheNamespaceRoot::from_base64) + .transpose() + .map_err(|error| anyhow!("Invalid persisted proxy transport cache root: {error}"))?; + #[cfg(not(target_os = "windows"))] + let cache_namespace_root_needs_persistence = false; + + Ok(LoadedProxyConfig { + config: persisted.config, + cache_namespace_root, + cache_namespace_root_needs_persistence, + #[cfg(target_os = "windows")] + roaming_config, + }) } // Initialize proxy on app startup if auto_start is enabled @@ -1104,6 +1410,7 @@ pub async fn init_proxy_on_startup_simple(app_handle: AppHandle) -> Result<()> { let _lifecycle_guard = proxy_state.lifecycle.lock().await; // Load saved config + cache_namespace_root_for_app(&app_handle, &proxy_state).await?; let config = load_saved_proxy_config(&app_handle).await?; // Check if auto-start is enabled and we have an API key diff --git a/frontend/src/ai/OpenAIContext.tsx b/frontend/src/ai/OpenAIContext.tsx index 3cead47cb..153da6ade 100644 --- a/frontend/src/ai/OpenAIContext.tsx +++ b/frontend/src/ai/OpenAIContext.tsx @@ -8,11 +8,11 @@ export const OpenAIProvider = ({ children }: { children: React.ReactNode }) => { throw new Error("VITE_OPEN_SECRET_API_URL must be set"); } - const { aiCustomFetch } = useOpenSecret(); - const access_token = window.localStorage.getItem("access_token"); + const { aiCustomFetch, auth } = useOpenSecret(); - // If we're not logged in we can't set up openai - if (!access_token) { + // V2 credentials are deliberately opaque to Maple. The provider's verified + // user state, rather than a legacy storage key, determines readiness. + if (auth.loading || !auth.user) { return {children}; } diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 2f4538c6e..cb3cd9bea 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -17,9 +17,8 @@ import { ThemeProvider } from "./contexts/ThemeContext"; import { ProxyEventListener } from "./components/ProxyEventListener"; import { UpdateEventListener } from "./components/UpdateEventListener"; import { TTSProvider } from "./services/tts/TTSContext"; -import { openSecretPcrEnvironment } from "./config/openSecretPcrEnvironment"; - -const DEFAULT_OPEN_SECRET_CLIENT_ID = "ba5a14b5-d915-47b1-b7b1-afda52bc5fc6"; +import { openSecretClientConfig } from "./config/openSecretClientConfig"; +import { MapleApiAuthInvalidationHandler } from "./components/MapleApiAuthInvalidationHandler"; // Create a new router instance const router = createRouter({ @@ -55,53 +54,12 @@ const queryClient = new QueryClient({ }); export default function App() { + const clientConfig = openSecretClientConfig(); + return ( - + @@ -112,6 +70,7 @@ export default function App() { + diff --git a/frontend/src/components/AppleAuthProvider.test.tsx b/frontend/src/components/AppleAuthProvider.test.tsx index 6c7a03cb6..4463d8362 100644 --- a/frontend/src/components/AppleAuthProvider.test.tsx +++ b/frontend/src/components/AppleAuthProvider.test.tsx @@ -88,12 +88,14 @@ mock.module("@opensecret/react", () => ({ })); const { initBillingService } = await import("@/billing/billingService"); +const { markTransportV2DesktopOAuth } = await import("@/services/desktopOAuthTransport"); const { AppleAuthProvider } = await import("./AppleAuthProvider"); const originalGlobals = { document: Object.getOwnPropertyDescriptor(globalThis, "document"), localStorage: Object.getOwnPropertyDescriptor(globalThis, "localStorage"), sessionStorage: Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"), + setTimeout: Object.getOwnPropertyDescriptor(globalThis, "setTimeout"), window: Object.getOwnPropertyDescriptor(globalThis, "window") }; @@ -119,6 +121,7 @@ describe("AppleAuthProvider", () => { let signInControls: SignInControl[]; let initiateAppleAuth: ReturnType; let handleAppleCallback: ReturnType; + let mintNativeHandoffGrant: ReturnType; let onError: ReturnType; let onSuccess: ReturnType; let redirectAfterLogin: ReturnType; @@ -136,6 +139,10 @@ describe("AppleAuthProvider", () => { initiateAppleAuth = mock(async () => ({ state: states.shift() ?? "unexpected-state" })); handleAppleCallback = mock(async () => {}); + mintNativeHandoffGrant = mock(async () => ({ + grant: "header.payload.signature", + expires_at: 1_800_000_000 + })); onError = mock(() => {}); onSuccess = mock(() => {}); redirectAfterLogin = mock(() => {}); @@ -149,7 +156,8 @@ describe("AppleAuthProvider", () => { currentOpenSecret = { initiateAppleAuth, - handleAppleCallback + handleAppleCallback, + mintNativeHandoffGrant }; initBillingService(currentOpenSecret as never); @@ -174,6 +182,10 @@ describe("AppleAuthProvider", () => { setGlobal("document", documentTarget); setGlobal("localStorage", localStorage); setGlobal("sessionStorage", sessionStorage); + setGlobal( + "setTimeout", + mock(() => 0) + ); setGlobal("window", windowValue); }); @@ -184,6 +196,7 @@ describe("AppleAuthProvider", () => { restoreGlobal("document", originalGlobals.document); restoreGlobal("localStorage", originalGlobals.localStorage); restoreGlobal("sessionStorage", originalGlobals.sessionStorage); + restoreGlobal("setTimeout", originalGlobals.setTimeout); restoreGlobal("window", originalGlobals.window); console.error = originalConsoleError; }); @@ -314,5 +327,25 @@ describe("AppleAuthProvider", () => { expect(onSuccess).toHaveBeenCalledTimes(1); expect(redirectAfterLogin).toHaveBeenCalledTimes(1); expect(redirectAfterLogin).toHaveBeenCalledWith("max"); + expect(mintNativeHandoffGrant).not.toHaveBeenCalled(); + }); + + test("mints the hosted Apple handoff grant for the persisted native session", async () => { + const nativeOAuthAttempt = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + const nativeSessionId = "abcdef12-2222-3333-4444-555555555555"; + markTransportV2DesktopOAuth(nativeOAuthAttempt, nativeSessionId); + + await act(async () => { + renderer = create(); + }); + const attempt = await startAttempt(); + await act(async () => { + attempt.control.resolve({ authorization: { code: "code", state: "state-one" } }); + await attempt.completion; + }); + + expect(handleAppleCallback).toHaveBeenCalledWith("code", "state-one", "invite"); + expect(mintNativeHandoffGrant).toHaveBeenCalledWith(nativeSessionId, nativeOAuthAttempt); + expect(onSuccess).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/components/AppleAuthProvider.tsx b/frontend/src/components/AppleAuthProvider.tsx index 5513ef751..d90c3c326 100644 --- a/frontend/src/components/AppleAuthProvider.tsx +++ b/frontend/src/components/AppleAuthProvider.tsx @@ -7,6 +7,13 @@ import { Button, type ButtonProps } from "./ui/button"; import { Apple } from "./icons/Apple"; import { getBillingService } from "@/billing/billingService"; import { getSafeInternalRedirect } from "@/utils/internalRedirect"; +import { + buildTransportV2NativeAuthDeepLink, + clearDesktopOAuthTransport, + isNativeOAuthRedirect, + readTransportV2DesktopOAuthAttempt, + readTransportV2DesktopOAuthSession +} from "@/services/desktopOAuthTransport"; interface AppleAuthProviderProps { onSuccess?: () => void; @@ -133,25 +140,25 @@ export function AppleAuthProvider({ console.warn("Failed to clear billing token:", billingError); } - const isTauriAuth = localStorage.getItem("redirect-to-native") === "true"; + const isTauriAuth = isNativeOAuthRedirect(); if (isTauriAuth) { - localStorage.removeItem("redirect-to-native"); - - const accessToken = localStorage.getItem("access_token") || ""; - const refreshToken = localStorage.getItem("refresh_token"); - let deepLinkUrl = `cloud.opensecret.maple://auth?access_token=${encodeURIComponent(accessToken)}`; - - if (refreshToken) { - deepLinkUrl += `&refresh_token=${encodeURIComponent(refreshToken)}`; + const nativeOAuthAttemptId = readTransportV2DesktopOAuthAttempt(); + const nativeSessionId = readTransportV2DesktopOAuthSession(); + if (!nativeOAuthAttemptId || !nativeSessionId) { + throw new Error("Desktop authentication state is missing or expired; please restart login"); } + const { grant } = await os.mintNativeHandoffGrant(nativeSessionId, nativeOAuthAttemptId); const postAuthRedirect = sessionStorage.getItem("post_auth_redirect"); sessionStorage.removeItem("post_auth_redirect"); const safePostAuthRedirect = getSafeInternalRedirect(postAuthRedirect); - if (!selectedPlan && safePostAuthRedirect) { - deepLinkUrl += `&next=${encodeURIComponent(safePostAuthRedirect)}`; - } + const deepLinkUrl = buildTransportV2NativeAuthDeepLink( + grant, + nativeSessionId, + !selectedPlan ? safePostAuthRedirect : null + ); + clearDesktopOAuthTransport(); setTimeout(() => { window.location.href = deepLinkUrl; diff --git a/frontend/src/components/DeepLinkHandler.test.tsx b/frontend/src/components/DeepLinkHandler.test.tsx index beab7c234..2151533a6 100644 --- a/frontend/src/components/DeepLinkHandler.test.tsx +++ b/frontend/src/components/DeepLinkHandler.test.tsx @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { act, create, type ReactTestRenderer } from "react-test-renderer"; -import { beginNativeOAuthAttempt } from "@/services/nativeOAuthAttempt"; class MemoryStorage implements Storage { private readonly values = new Map(); @@ -37,12 +36,33 @@ interface DeepLinkEvent { let deepLinkListener: ((event: DeepLinkEvent) => void) | undefined; let currentUser: { id: string } | undefined; const unlisten = mock(() => {}); +const importAuthBundle = mock(async (bundle: string, apiUrl: string) => { + void bundle; + void apiUrl; +}); +const nativeOAuthAttemptId = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; +const nativeSessionId = "abcdef12-2222-3333-4444-555555555555"; +const nativeInvoke = mock(async (command: string) => { + if (command === "native_oauth_begin") { + return { nativeOAuthAttempt: nativeOAuthAttemptId, sessionId: nativeSessionId }; + } + if (command === "native_oauth_redeem") { + return { userId: "user-one", authBundle: "opaque-redeemed-bundle" }; + } + return undefined; +}); + +mock.module("@tauri-apps/api/core", () => ({ invoke: nativeInvoke })); mock.module("@opensecret/react", () => ({ + exportTransportV2AuthBundle: async () => "test-bundle", + importTransportV2AuthBundle: importAuthBundle, useOpenSecret: () => ({ auth: { user: currentUser } }) })); +const realPlatform = await import("@/utils/platform"); mock.module("@/utils/platform", () => ({ + ...realPlatform, isTauri: () => true })); @@ -53,6 +73,7 @@ mock.module("@tauri-apps/api/event", () => ({ } })); +const { beginNativeOAuthAttempt } = await import("@/services/nativeOAuthAttempt"); const { DeepLinkHandler } = await import("./DeepLinkHandler"); const originalGlobals = { @@ -82,6 +103,8 @@ describe("DeepLinkHandler native auth callbacks", () => { renderer = null; storage = new MemoryStorage(); location = { href: "tauri://localhost/" }; + importAuthBundle.mockClear(); + nativeInvoke.mockClear(); Object.defineProperty(globalThis, "localStorage", { configurable: true, @@ -119,68 +142,99 @@ describe("DeepLinkHandler native auth callbacks", () => { console.warn = originalConsoleWarn; }); - function emitAuthLink(accessToken = "incoming-access", refreshToken = "incoming-refresh"): void { + async function emitAuthLink(handoffGrant = "header.payload.signature"): Promise { const query = new URLSearchParams({ - access_token: accessToken, - refresh_token: refreshToken, + handoff_grant: handoffGrant, + native_session_id: nativeSessionId, next: "/settings" }); - deepLinkListener?.({ payload: `cloud.opensecret.maple://auth?${query}` }); + await act(async () => { + deepLinkListener?.({ payload: `cloud.opensecret.maple://auth?${query}` }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + } + + async function mirrorNativeAttempt(): Promise { + return (await beginNativeOAuthAttempt("https://api.example.test")).nativeOAuthAttempt; } - test("preserves an authenticated session and consumes any pending marker", () => { - beginNativeOAuthAttempt(); - storage.setItem("access_token", "current-access"); - storage.setItem("refresh_token", "current-refresh"); + test("preserves an authenticated session and consumes any pending marker", async () => { + await mirrorNativeAttempt(); currentUser = { id: "current-user" }; act(() => renderer?.update()); - emitAuthLink(); + await emitAuthLink("header.payload.signature"); - expect(storage.getItem("access_token")).toBe("current-access"); - expect(storage.getItem("refresh_token")).toBe("current-refresh"); + expect(importAuthBundle).not.toHaveBeenCalled(); expect(location.href).toBe("tauri://localhost/"); - storage.removeItem("access_token"); - storage.removeItem("refresh_token"); - emitAuthLink(); - expect(storage.getItem("access_token")).toBeNull(); + currentUser = undefined; + act(() => renderer?.update()); + await emitAuthLink("header.second.signature"); + expect(importAuthBundle).not.toHaveBeenCalled(); }); - test("rejects an unsolicited auth callback without a pending marker", () => { - emitAuthLink(); + test("rejects an unsolicited auth callback without a pending marker", async () => { + await emitAuthLink(); - expect(storage.getItem("access_token")).toBeNull(); - expect(storage.getItem("refresh_token")).toBeNull(); + expect(importAuthBundle).not.toHaveBeenCalled(); expect(location.href).toBe("tauri://localhost/"); }); - test("accepts a pending auth callback once and preserves its safe redirect", () => { - beginNativeOAuthAttempt(); - storage.setItem("access_token", "stale-access"); - storage.setItem("refresh_token", "stale-refresh"); + test("accepts a pending auth callback once and preserves its safe redirect", async () => { + await mirrorNativeAttempt(); - emitAuthLink(); + await emitAuthLink("header.payload.signature"); - expect(storage.getItem("access_token")).toBe("incoming-access"); - expect(storage.getItem("refresh_token")).toBe("incoming-refresh"); + expect(importAuthBundle).toHaveBeenCalledTimes(1); + expect(importAuthBundle.mock.calls[0]?.[0]).toBe("opaque-redeemed-bundle"); + expect(nativeInvoke).toHaveBeenCalledWith("native_oauth_redeem", { + request: { + handoffGrant: "header.payload.signature", + nativeSessionId + } + }); expect(location.href).toBe("/settings"); - storage.removeItem("access_token"); - storage.removeItem("refresh_token"); - emitAuthLink("replayed-access", "replayed-refresh"); - expect(storage.getItem("access_token")).toBeNull(); + location.href = "tauri://localhost/"; + await emitAuthLink("header.payload.signature"); + expect(importAuthBundle).toHaveBeenCalledTimes(1); + expect( + nativeInvoke.mock.calls.filter(([command]) => command === "native_oauth_redeem") + ).toHaveLength(1); + expect(location.href).toBe("tauri://localhost/"); }); - test("does not consume the pending marker for a callback missing required tokens", () => { - beginNativeOAuthAttempt(); - deepLinkListener?.({ - payload: "cloud.opensecret.maple://auth?access_token=incomplete" + test("does not consume the pending marker for a callback missing the grant", async () => { + await mirrorNativeAttempt(); + await act(async () => { + deepLinkListener?.({ + payload: "cloud.opensecret.maple://auth" + }); + await Promise.resolve(); }); - emitAuthLink(); + await emitAuthLink("header.payload.signature"); + + expect(importAuthBundle).toHaveBeenCalledTimes(1); + expect(importAuthBundle.mock.calls[0]?.[0]).toBe("opaque-redeemed-bundle"); + }); + + test("does not treat an unrelated custom-scheme host as an auth callback", async () => { + await mirrorNativeAttempt(); + await act(async () => { + deepLinkListener?.({ + payload: "cloud.opensecret.maple://unrelated?handoff_grant=header.payload.signature" + }); + await Promise.resolve(); + }); - expect(storage.getItem("access_token")).toBe("incoming-access"); - expect(storage.getItem("refresh_token")).toBe("incoming-refresh"); + expect(importAuthBundle).not.toHaveBeenCalled(); + await emitAuthLink("header.payload.signature"); + expect(importAuthBundle).toHaveBeenCalledTimes(1); }); }); diff --git a/frontend/src/components/DeepLinkHandler.tsx b/frontend/src/components/DeepLinkHandler.tsx index 5513c39fa..4a8d0b35d 100644 --- a/frontend/src/components/DeepLinkHandler.tsx +++ b/frontend/src/components/DeepLinkHandler.tsx @@ -1,9 +1,16 @@ import { useEffect, useRef } from "react"; -import { useOpenSecret } from "@opensecret/react"; +import { importTransportV2AuthBundle, useOpenSecret } from "@opensecret/react"; import { isTauri } from "@/utils/platform"; import { listen } from "@tauri-apps/api/event"; import { getSafeInternalRedirect } from "@/utils/internalRedirect"; -import { authorizeNativeOAuthCallback } from "@/services/nativeOAuthAttempt"; +import { + authorizeNativeOAuthCallback, + consumeNativeOAuthAttempt, + isNativeOAuthSessionId, + readPendingNativeOAuthAttemptId, + redeemNativeOAuthGrant +} from "@/services/nativeOAuthAttempt"; +import { TRANSPORT_V2_NATIVE_SESSION_QUERY } from "@/services/desktopOAuthTransport"; // For direct deep link handling, we'll listen to our custom event // If we had the types installed, we would use: @@ -27,96 +34,118 @@ export function DeepLinkHandler() { const url = event.payload; console.log("[Deep Link] Received callback"); - try { - // Parse the URL to extract parameters - const urlObj = new URL(url); - // The URL path structure will be: cloud.opensecret.maple://path?params - const pathParts = urlObj.pathname.split("/").filter(Boolean); - const firstPathPart = pathParts[0] || ""; - - // Handle different types of deep links - if (firstPathPart === "auth" || firstPathPart === "") { - // Handle auth deep links - const accessToken = urlObj.searchParams.get("access_token"); - const refreshToken = urlObj.searchParams.get("refresh_token"); - const next = urlObj.searchParams.get("next"); - const safeNext = getSafeInternalRedirect(next) ?? "/"; - - if (accessToken && refreshToken) { - const authorization = authorizeNativeOAuthCallback(isAuthenticatedRef.current); - if (authorization === "already_authenticated") { - console.warn("[Deep Link] Ignoring auth callback for an existing session"); - return; + void (async () => { + try { + // Parse the URL to extract parameters + const urlObj = new URL(url); + // The URL path structure will be: cloud.opensecret.maple://path?params + const pathParts = urlObj.pathname.split("/").filter(Boolean); + // Custom-scheme links normally encode the action as the host + // (`cloud.opensecret.maple://auth`). Retain path support for + // the existing triple-slash form without treating every empty + // path as an authentication callback. + const firstPathPart = pathParts[0] || urlObj.hostname; + + // Handle different types of deep links + if (firstPathPart === "auth") { + // Handle auth deep links + const handoffGrant = urlObj.searchParams.get("handoff_grant"); + const nativeSessionId = urlObj.searchParams.get( + TRANSPORT_V2_NATIVE_SESSION_QUERY + ); + const next = urlObj.searchParams.get("next"); + const safeNext = getSafeInternalRedirect(next) ?? "/"; + + if (handoffGrant && isNativeOAuthSessionId(nativeSessionId)) { + const nativeOAuthAttemptId = readPendingNativeOAuthAttemptId(); + const authorization = authorizeNativeOAuthCallback(isAuthenticatedRef.current); + if (authorization === "already_authenticated") { + console.warn("[Deep Link] Ignoring auth callback for an existing session"); + return; + } + if (authorization === "missing_or_expired_attempt") { + console.warn("[Deep Link] Ignoring unsolicited or expired auth callback"); + return; + } + + if (!nativeOAuthAttemptId) return; + const { authBundle } = await redeemNativeOAuthGrant( + handoffGrant, + nativeSessionId + ); + if (!authBundle.trim()) { + throw new Error("Native OAuth redemption returned invalid credentials"); + } + await importTransportV2AuthBundle( + authBundle, + import.meta.env.VITE_OPEN_SECRET_API_URL + ); + if (!consumeNativeOAuthAttempt(nativeOAuthAttemptId)) { + throw new Error("Native OAuth state changed during redemption"); + } + console.log("[Deep Link] Authentication grant accepted"); + + // Refresh the app state to reflect the logged-in status + window.location.href = safeNext; // Reload the app at the requested internal route + } else { + // Check required shape before consuming the one-time callback + // marker so a truncated URL cannot invalidate a later retry. + console.error("[Deep Link] Authentication callback is missing required state"); } - if (authorization === "missing_or_expired_attempt") { - console.warn("[Deep Link] Ignoring unsolicited or expired auth callback"); - return; - } - - console.log("[Deep Link] Auth tokens received"); - - // Store the tokens in localStorage with consistent naming - localStorage.setItem("access_token", accessToken); - localStorage.setItem("refresh_token", refreshToken); - - // Refresh the app state to reflect the logged-in status - window.location.href = safeNext; // Reload the app at the requested internal route - } else { - console.error("[Deep Link] Missing tokens in auth deep link"); - } - } else if ( - firstPathPart === "payment" || - firstPathPart === "payment-success" || - firstPathPart === "payment-success-credits" || - firstPathPart === "payment-canceled" || - urlObj.searchParams.has("payment_success") || - urlObj.searchParams.has("success") || - urlObj.searchParams.has("canceled") || - urlObj.searchParams.has("payment_canceled") - ) { - // Handle payment deep links from various sources - const isSuccess = + } else if ( + firstPathPart === "payment" || firstPathPart === "payment-success" || firstPathPart === "payment-success-credits" || - urlObj.searchParams.get("success") === "true" || - urlObj.searchParams.get("payment_success") === "true"; - - const isCreditSuccess = firstPathPart === "payment-success-credits"; - - const isCanceled = firstPathPart === "payment-canceled" || - urlObj.searchParams.get("canceled") === "true" || - urlObj.searchParams.has("payment_canceled"); - - console.log("[Deep Link] Payment callback received:", { - isSuccess, - isCanceled, - path: firstPathPart, - source: urlObj.searchParams.get("source") - }); - - // Use window.location instead of navigate - if (isCreditSuccess) { - // Keep the established root callback contract; the home route bridges it into - // the dedicated API credits settings page. - window.location.href = "/?credits_success=true"; - } else if (isSuccess) { - // Navigate to the success page or show a success message - window.location.href = "/pricing?success=true"; - } else if (isCanceled) { - // Navigate to the canceled page or show a canceled message - window.location.href = "/pricing?canceled=true"; + urlObj.searchParams.has("payment_success") || + urlObj.searchParams.has("success") || + urlObj.searchParams.has("canceled") || + urlObj.searchParams.has("payment_canceled") + ) { + // Handle payment deep links from various sources + const isSuccess = + firstPathPart === "payment-success" || + firstPathPart === "payment-success-credits" || + urlObj.searchParams.get("success") === "true" || + urlObj.searchParams.get("payment_success") === "true"; + + const isCreditSuccess = firstPathPart === "payment-success-credits"; + + const isCanceled = + firstPathPart === "payment-canceled" || + urlObj.searchParams.get("canceled") === "true" || + urlObj.searchParams.has("payment_canceled"); + + console.log("[Deep Link] Payment callback received:", { + isSuccess, + isCanceled, + path: firstPathPart, + source: urlObj.searchParams.get("source") + }); + + // Use window.location instead of navigate + if (isCreditSuccess) { + // Keep the established root callback contract; the home route bridges it into + // the dedicated API credits settings page. + window.location.href = "/?credits_success=true"; + } else if (isSuccess) { + // Navigate to the success page or show a success message + window.location.href = "/pricing?success=true"; + } else if (isCanceled) { + // Navigate to the canceled page or show a canceled message + window.location.href = "/pricing?canceled=true"; + } else { + // Handle unknown payment status + console.warn("[Deep Link] Unknown payment status in callback"); + window.location.href = "/pricing"; + } } else { - // Handle unknown payment status - console.warn("[Deep Link] Unknown payment status in callback"); - window.location.href = "/pricing"; + console.warn("[Deep Link] Unknown deep link type:", firstPathPart); } - } else { - console.warn("[Deep Link] Unknown deep link type:", firstPathPart); + } catch (error) { + console.error("[Deep Link] Failed to process deep link:", error); } - } catch (error) { - console.error("[Deep Link] Failed to process deep link:", error); - } + })(); }); console.log("[Deep Link] Handler setup complete"); diff --git a/frontend/src/components/MapleApiAuthInvalidationHandler.test.tsx b/frontend/src/components/MapleApiAuthInvalidationHandler.test.tsx new file mode 100644 index 000000000..2bd2c935b --- /dev/null +++ b/frontend/src/components/MapleApiAuthInvalidationHandler.test.tsx @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import type { MapleApiAuthInvalidated } from "@/services/mapleApiAuthService"; + +let currentUserId: string | null = null; +let releaseSignOut: (() => void) | null = null; +const signOut = mock( + async () => + await new Promise((resolve) => { + releaseSignOut = resolve; + }) +); + +mock.module("@opensecret/react", () => ({ + exportTransportV2AuthBundle: async () => "test-bundle", + importTransportV2AuthBundle: async () => {}, + useOpenSecret: () => ({ + auth: { user: currentUserId ? { user: { id: currentUserId } } : undefined }, + signOut + }) +})); + +const { MapleApiAuthInvalidationHandler } = await import("./MapleApiAuthInvalidationHandler"); + +class FakeInvalidationSource { + handler: ((event: MapleApiAuthInvalidated) => void) | null = null; + + subscribeInvalidation(handler: (event: MapleApiAuthInvalidated) => void): () => void { + this.handler = handler; + return () => { + if (this.handler === handler) this.handler = null; + }; + } + + emit(userId: string): void { + this.handler?.({ userId }); + } +} + +describe("MapleApiAuthInvalidationHandler", () => { + let renderer: ReactTestRenderer | null = null; + let source: FakeInvalidationSource; + + beforeEach(() => { + currentUserId = "user-a"; + releaseSignOut = null; + signOut.mockClear(); + source = new FakeInvalidationSource(); + act(() => { + renderer = create(); + }); + }); + + afterEach(() => { + releaseSignOut?.(); + act(() => renderer?.unmount()); + renderer = null; + }); + + test("signs out only the matching UI account and coalesces duplicate native events", async () => { + source.emit("user-b"); + source.emit("USER-A"); + source.emit("user-a"); + + expect(signOut).toHaveBeenCalledTimes(1); + releaseSignOut?.(); + await act(async () => await Promise.resolve()); + }); + + test("uses the latest rendered account when a native event arrives", () => { + currentUserId = "user-b"; + act(() => renderer?.update()); + + source.emit("user-a"); + expect(signOut).not.toHaveBeenCalled(); + source.emit("user-b"); + expect(signOut).toHaveBeenCalledTimes(1); + }); + + test("unsubscribes when the application boundary unmounts", () => { + act(() => renderer?.unmount()); + renderer = null; + + source.emit("user-a"); + expect(signOut).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/MapleApiAuthInvalidationHandler.tsx b/frontend/src/components/MapleApiAuthInvalidationHandler.tsx new file mode 100644 index 000000000..53370a417 --- /dev/null +++ b/frontend/src/components/MapleApiAuthInvalidationHandler.tsx @@ -0,0 +1,47 @@ +import { useEffect, useRef } from "react"; +import { useOpenSecret } from "@opensecret/react"; +import { mapleApiAuthService, type MapleApiAuthInvalidated } from "@/services/mapleApiAuthService"; + +interface MapleApiAuthInvalidationSource { + subscribeInvalidation(handler: (event: MapleApiAuthInvalidated) => void): () => void; +} + +export function MapleApiAuthInvalidationHandler({ + source = mapleApiAuthService +}: { + source?: MapleApiAuthInvalidationSource; +}) { + const os = useOpenSecret(); + const userIdRef = useRef(null); + const signOutRef = useRef(os.signOut); + const invalidatingUserIdRef = useRef(null); + userIdRef.current = os.auth.user?.user.id.toLowerCase() ?? null; + signOutRef.current = os.signOut; + + useEffect( + () => + source.subscribeInvalidation(({ userId }) => { + const normalizedUserId = userId.trim().toLowerCase(); + if ( + !normalizedUserId || + userIdRef.current !== normalizedUserId || + invalidatingUserIdRef.current === normalizedUserId + ) { + return; + } + + invalidatingUserIdRef.current = normalizedUserId; + void signOutRef + .current() + .catch(() => undefined) + .finally(() => { + if (invalidatingUserIdRef.current === normalizedUserId) { + invalidatingUserIdRef.current = null; + } + }); + }), + [source] + ); + + return null; +} diff --git a/frontend/src/config/openSecretClientConfig.ts b/frontend/src/config/openSecretClientConfig.ts new file mode 100644 index 000000000..7b78e69f8 --- /dev/null +++ b/frontend/src/config/openSecretClientConfig.ts @@ -0,0 +1,54 @@ +import { openSecretPcrEnvironment } from "./openSecretPcrEnvironment"; + +const DEFAULT_OPEN_SECRET_CLIENT_ID = "ba5a14b5-d915-47b1-b7b1-afda52bc5fc6"; + +const PCR0_VALUES = [ + "ed9109c16f30a470cf0ea2251816789b4ffa510c990118323ce94a2364b9bf05bdb8777959cbac86f5cabc4852e0da71", + "4f2bcdf16c38842e1a45defd944d24ea58bb5bcb76491843223022acfe9eb6f1ff79b2cb9a6b2a9219daf9c7bf40fa37", + "b8ee4b511ef2c9c6ab3e5c0840c5df2218fbb4d9df88254ece7af9462677e55aa5a03838f3ae432d86ca1cb6f992eee7", + "33ffe5cae0f72cfe904bde8019ad98efa0ce5db2800f37c5d4149461023d1f70ea77e4f58ae1327ff46ed6a34045d6e2", + "a1398fa2946b6ed4b96a1a992ee668aef3661329690f87d44cad5b646ce33e3b16a55674b1d6d54d115a5520801b97d6", + "878dc4111e94722f3d33b202dc1368916af2eb486e74b3d94c9dfbcb3d981fa652827ea8e951ddfe06d1cefb482e431c", + "4e242871fecc14933c889908a6a7593de574c2655a47ffa163c5fd7ba41d063152ef441bd555ac7f8569eac4fd7cbc8b", + "095d38ba5c9c7ad1cfe5832d3dd8304b020392867aeef84f47e08b4305b867540b0ff5b2eb7d279de410e19ad937896e", + "72c9a1dc207d919196c78f845c0f5fd4b3b3a690e024a3dd599f160be04875fbe52983773909a9f1584105f7d5103538", + "a5ae21e211fa709dbced7fec7fff0eb86001174365a29271c07d7fb55fd9f37c7e2ddef1b01f39e977ca246277efeef2", + "2520e5067830a34fc457b6360358e3754d53ab855f4a05a81312f2a2ed0bf893c5ec43d1325972efbe3a8f2b79303734", + "a275a3877972c670c4f43e658cc9296838f79a96a26429877f74285c3a088d426583d3e2f6f99cfd96c70fdfa1475266", + "8de5541089649e9edb2cd96fafb90716aa298483447e459708e8840b1f82a557c9d9ff6ae1fd2461b04310e7d9400d7d", + "02a41da2df084fd1dee420d7717bef6dc0120f1d6a0b7fded3f4c7a539be4044b3061c71bc7156731db1fb66494097b0", + "d9638aebacf2bf15ef0ab7d394320a3aa5ebde9f0e8911d2d2a0b49a2792f3825e6f6ca56960a63e91857398125d8038", + "41786ecb8e012b910cd095ad5f8b5acefcaf80df3cf8e909499da45dd594c7c4c28302b5dde551d870555bd389a1e2c4" +]; + +const PCR0_DEV_VALUES = [ + "799600ba64a29e360b1651f4ced6c9ca5323094a45294551327b996062c3f21e6fef651e7e3d97ec8d25be87b9935b4f", + "2fd9d4f716fd28336d96bc1a20b18a727c2d18f292577ba99323acfc8fb08959428a123b7acff478994c4f961247a0c7", + "4292db2a90ce5ea6f6e2766e0238a328c81dc060a1f3175bced2e94a10e0490d3ff9125d774dafdff969ac661778e757", + "f58409ae1bc8600c887fef5cc4055149c88c94b41c2b3e268826af7b43a1cdbacffdb2c96bf5972120c6460ab83fe89e", + "6fcdb8086806a96c421c08eaf67cebf164aa898798b6f91b072c884773bc6ed64fe8f5af644fe35411195167b0e4a5f1", + "0042958bde1fdd1bcbd4085ec94456c49e7bc5d2c3368f6f34edd6f339193cb7b53929d299eaf6a220ed5b7691f8618a", + "583ac140e0454dd4766a07c147cb6d90d5430d6bc9c1571da19c781dea4027e1c434273caba584440180ca42c2db84d5", + "4451e47ddb4be8a63492e62bc400e69d924188040805c658334f708e8682d308af3feb16018e98a5589c345d28437a6b", + "5bc5a32791948dc7e315d01ec787307799bb6f70903d14c20dc47f19bb0ef3830eb3b2c5b04b7ae5b04717046b357a14", + "4243170eeb11d38cf9bbee48b754bccfe97385b4639051efe97cda50086784cd32009dcb89a0fb1098558f22dc55b4e6", + "be3de8fa74f42cc5165823da63aa283f1c8dfedb5e27e0bfa281c6dbd12d5b5bfd9bef591200175c44dbf0c504f5b0a5", + "ab7e90e1894f75fbd423f3d0027973b611beff7402bd224dcd0f162968fb9678f08c92970d16a444edc934aa6ecd7d62", + "8ba003e9d552d262cfd40a77eced199cfd0e776d1fd69d5ed6b7e6c6c92b91a0481e965247502f95a20478fe0a8f3de7", + "2e6e8f86a657f6daed8e699c0b74bf02d6c7d6414638b1032e6addd2bca7d208a0e8aa4961eb8e926c54dc58fd63d401", + "6b15f0571de13a6357e646bbe3772a8fe32fdd85b07ba97b3a6f95bdc43023dd9deb5710c26b75346de90157d9ecdd1f", + "2596e528703abda188de27b6995f8d3cc553502e4acbb4d06db1dc8239b428638447adff38ccce358ef9fe34c2e0bccc", + "e36d72989d89818b77ab1012cf875c46c0d7fb5389fd559b9ea0950231ae1cc17dd222e5d19a84363a27a3cd7f268de6" +]; + +export function openSecretClientConfig() { + return { + apiUrl: import.meta.env.VITE_OPEN_SECRET_API_URL, + clientId: import.meta.env.VITE_CLIENT_ID || DEFAULT_OPEN_SECRET_CLIENT_ID, + pcrConfig: { + environment: openSecretPcrEnvironment(), + pcr0Values: [...PCR0_VALUES], + pcr0DevValues: [...PCR0_DEV_VALUES] + } + }; +} diff --git a/frontend/src/legacy/LegacyDesktopOAuthApp.tsx b/frontend/src/legacy/LegacyDesktopOAuthApp.tsx new file mode 100644 index 000000000..57d24a9ae --- /dev/null +++ b/frontend/src/legacy/LegacyDesktopOAuthApp.tsx @@ -0,0 +1,329 @@ +import "../index.css"; + +import { useEffect, useRef, useState } from "react"; +import { + OpenSecretProvider as LegacyOpenSecretProvider, + useOpenSecret as useLegacyOpenSecret +} from "@opensecret/react-v1"; +import { sha256 } from "@noble/hashes/sha256"; +import { bytesToHex } from "@noble/hashes/utils"; +import { v4 as uuidv4 } from "uuid"; +import { Apple } from "@/components/icons/Apple"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { openSecretClientConfig } from "@/config/openSecretClientConfig"; +import { + clearDesktopOAuthTransport, + markDesktopOAuthTransport +} from "@/services/desktopOAuthTransport"; +import { getSafeInternalRedirect } from "@/utils/internalRedirect"; +import { Loader2 } from "lucide-react"; + +type OAuthProvider = "github" | "google" | "apple"; + +function readProvider(value: string | null): OAuthProvider | null { + return value === "github" || value === "google" || value === "apple" ? value : null; +} + +function providerFromCallbackPath(pathname: string): OAuthProvider | null { + return readProvider(pathname.match(/^\/auth\/(github|google|apple)\/callback$/)?.[1] ?? null); +} + +function formatProviderName(provider: OAuthProvider): string { + if (provider === "github") return "GitHub"; + if (provider === "google") return "Google"; + return "Apple"; +} + +function clearBillingToken(): void { + sessionStorage.removeItem("maple_billing_token"); +} + +function buildLegacyDeepLink(selectedPlan?: string): string { + const accessToken = localStorage.getItem("access_token")?.trim(); + const refreshToken = localStorage.getItem("refresh_token")?.trim(); + if (!accessToken || !refreshToken) { + throw new Error("The legacy authentication response did not include both credentials"); + } + + const deepLink = new URL("cloud.opensecret.maple://auth"); + deepLink.searchParams.set("access_token", accessToken); + deepLink.searchParams.set("refresh_token", refreshToken); + + const postAuthRedirect = getSafeInternalRedirect(sessionStorage.getItem("post_auth_redirect")); + sessionStorage.removeItem("post_auth_redirect"); + sessionStorage.removeItem("selected_plan"); + if (!selectedPlan && postAuthRedirect) { + deepLink.searchParams.set("next", postAuthRedirect); + } + + clearDesktopOAuthTransport(); + return deepLink.toString(); +} + +function LegacyDesktopOAuthContent() { + const pathname = window.location.pathname; + const callbackProvider = providerFromCallbackPath(pathname); + if (callbackProvider) { + return ; + } + return ; +} + +function LegacyOAuthInitiation() { + const os = useLegacyOpenSecret(); + const search = new URLSearchParams(window.location.search); + const provider = readProvider(search.get("provider")); + const selectedPlan = search.get("selected_plan") ?? undefined; + const next = getSafeInternalRedirect(search.get("next")); + const [error, setError] = useState(null); + const [nativeRedirectUrl, setNativeRedirectUrl] = useState(null); + const started = useRef(false); + + useEffect(() => { + if (started.current || !provider) return; + started.current = true; + markDesktopOAuthTransport("v1"); + + sessionStorage.removeItem("selected_plan"); + if (selectedPlan) sessionStorage.setItem("selected_plan", selectedPlan); + sessionStorage.removeItem("post_auth_redirect"); + if (next) sessionStorage.setItem("post_auth_redirect", next); + + if (provider === "apple") return; + + const initiate = provider === "github" ? os.initiateGitHubAuth : os.initiateGoogleAuth; + void initiate("") + .then(({ auth_url }) => { + window.location.href = auth_url; + }) + .catch((cause: unknown) => { + console.error(`Failed to initiate legacy ${provider} login:`, cause); + setError(`Failed to initiate ${formatProviderName(provider)} login`); + }); + }, [next, os.initiateGitHubAuth, os.initiateGoogleAuth, provider, selectedPlan]); + + const finishAppleAuth = () => { + try { + clearBillingToken(); + const deepLinkUrl = buildLegacyDeepLink(selectedPlan); + setNativeRedirectUrl(deepLinkUrl); + setTimeout(() => { + window.location.href = deepLinkUrl; + }, 1000); + } catch (cause) { + console.error("Failed to finish legacy Apple login:", cause); + setError(cause instanceof Error ? cause.message : "Failed to finish Apple login"); + } + }; + + if (!provider) { + return ; + } + + if (error) return ; + if (nativeRedirectUrl) { + return ; + } + + if (provider === "apple") { + return ( + + + Apple Sign In + + +

Click the button below to sign in with Apple:

+ setError(cause.message)} + /> +
+
+ ); + } + + return ; +} + +function LegacyOAuthCallback({ provider }: { provider: OAuthProvider }) { + const os = useLegacyOpenSecret(); + const [error, setError] = useState(null); + const [nativeRedirectUrl, setNativeRedirectUrl] = useState(null); + const processed = useRef(false); + + useEffect(() => { + if (processed.current) return; + processed.current = true; + + const search = new URLSearchParams(window.location.search); + const code = search.get("code"); + const state = search.get("state"); + if (!code || !state) { + setError("Invalid callback parameters"); + return; + } + + const handle = + provider === "github" + ? os.handleGitHubCallback + : provider === "google" + ? os.handleGoogleCallback + : os.handleAppleCallback; + + void handle(code, state, "") + .then(() => { + clearBillingToken(); + const selectedPlan = sessionStorage.getItem("selected_plan") ?? undefined; + const deepLinkUrl = buildLegacyDeepLink(selectedPlan); + setNativeRedirectUrl(deepLinkUrl); + setTimeout(() => { + window.location.href = deepLinkUrl; + }, 1000); + }) + .catch((cause: unknown) => { + console.error(`Legacy ${provider} authentication callback failed:`, cause); + setError(cause instanceof Error ? cause.message : "Authentication failed"); + }); + }, [os.handleAppleCallback, os.handleGitHubCallback, os.handleGoogleCallback, provider]); + + if (error) return ; + if (nativeRedirectUrl) { + return ; + } + return ; +} + +function LegacyAppleButton({ + onAuthenticated, + onError +}: { + onAuthenticated: () => void; + onError: (error: Error) => void; +}) { + const os = useLegacyOpenSecret(); + const scriptLoaded = useRef(false); + const pending = useRef(false); + + useEffect(() => { + if (scriptLoaded.current) return; + const script = document.createElement("script"); + script.src = + "https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"; + script.async = true; + document.head.appendChild(script); + scriptLoaded.current = true; + return () => { + script.remove(); + scriptLoaded.current = false; + }; + }, []); + + const signIn = async () => { + if (pending.current) return; + pending.current = true; + try { + if (!window.AppleID) throw new Error("Apple Sign In SDK not loaded"); + const initiation = await os.initiateAppleAuth(""); + const rawNonce = uuidv4(); + const hashedNonce = bytesToHex(sha256(new TextEncoder().encode(rawNonce))); + sessionStorage.setItem("apple_auth_nonce", rawNonce); + sessionStorage.setItem("apple_auth_state", initiation.state || ""); + window.AppleID.auth.init({ + clientId: "cloud.opensecret.maple.services", + scope: "name email", + redirectURI: window.location.origin + "/auth/apple/callback", + state: initiation.state || "", + nonce: hashedNonce, + usePopup: true + }); + + const result = await window.AppleID.auth.signIn(); + if (!result.authorization?.code || !result.authorization.state) { + throw new Error("Missing required authentication data"); + } + await os.handleAppleCallback(result.authorization.code, result.authorization.state, ""); + sessionStorage.removeItem("apple_auth_state"); + onAuthenticated(); + } catch (cause) { + const error = cause instanceof Error ? cause : new Error("Apple authentication failed"); + if ( + error.message !== "user_cancelled_authorize" && + error.message !== "popup_closed_by_user" + ) { + console.error("Legacy Apple authentication failed:", error); + onError(error); + } + } finally { + pending.current = false; + } + }; + + return ( + + ); +} + +function LegacyProcessing({ provider }: { provider: OAuthProvider }) { + return ( + + + Processing {formatProviderName(provider)} Login + + +

Completing authentication...

+
+ +
+
+
+ ); +} + +function LegacySuccess({ + provider, + deepLinkUrl +}: { + provider: OAuthProvider; + deepLinkUrl: string; +}) { + return ( + + + {formatProviderName(provider)} Authentication Successful + + +

Authentication successful! Tap the button below to return to Maple.

+
+ +
+
+
+ ); +} + +function LegacyError({ message }: { message: string }) { + return ( + + + Authentication Failed + + +

{message}

+ +
+
+ ); +} + +export default function LegacyDesktopOAuthApp() { + const config = openSecretClientConfig(); + return ( + + + + ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 995334587..a818b2db3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { isTauriDesktop, waitForPlatform } from "@/utils/platform"; import { restoreChatTypographyAtLaunch } from "@/services/chatTypographyPreferences"; import { restoreWorkspaceModeAtLaunch } from "@/services/workspaceModePreference"; +import { shouldLoadLegacyDesktopOAuth } from "@/services/desktopOAuthTransport"; // Initialize platform detection before rendering async function initializeApp() { @@ -17,7 +18,9 @@ async function initializeApp() { // Create the router only after restoring the launch route so its first // location snapshot matches the user's saved mode. - const { default: App } = await import("./app"); + const { default: App } = shouldLoadLegacyDesktopOAuth(window.location) + ? await import("./legacy/LegacyDesktopOAuthApp") + : await import("./app"); // Render the app const rootElement = document.getElementById("root")!; diff --git a/frontend/src/routes/auth.$provider.callback.tsx b/frontend/src/routes/auth.$provider.callback.tsx index 640b6375a..ed620006d 100644 --- a/frontend/src/routes/auth.$provider.callback.tsx +++ b/frontend/src/routes/auth.$provider.callback.tsx @@ -1,5 +1,5 @@ import { createFileRoute, useNavigate, useRouter, Link } from "@tanstack/react-router"; -import { useEffect, useState, useRef } from "react"; +import { useCallback, useEffect, useState, useRef } from "react"; import { useOpenSecret } from "@opensecret/react"; import { AlertDestructive } from "@/components/AlertDestructive"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -7,6 +7,13 @@ import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { getBillingService } from "@/billing/billingService"; import { getSafeInternalRedirect, navigateToSafeInternalRedirect } from "@/utils/internalRedirect"; +import { + buildTransportV2NativeAuthDeepLink, + clearDesktopOAuthTransport, + isNativeOAuthRedirect, + readTransportV2DesktopOAuthAttempt, + readTransportV2DesktopOAuthSession +} from "@/services/desktopOAuthTransport"; export const Route = createFileRoute("/auth/$provider/callback")({ component: OAuthCallback @@ -32,27 +39,26 @@ function OAuthCallback() { const [nativeRedirectUrl, setNativeRedirectUrl] = useState(null); const navigate = useNavigate(); const router = useRouter(); - const { handleGitHubCallback, handleGoogleCallback, handleAppleCallback } = useOpenSecret(); + const { + handleGitHubCallback, + handleGoogleCallback, + handleAppleCallback, + mintNativeHandoffGrant + } = useOpenSecret(); const processedRef = useRef(false); // Helper functions for the callback process - const handleSuccessfulAuth = () => { + const handleSuccessfulAuth = useCallback(async () => { // Check if this is a Tauri app auth flow (desktop or mobile) - const isTauriAuth = localStorage.getItem("redirect-to-native") === "true"; - - // Clear the flag - localStorage.removeItem("redirect-to-native"); + const isTauriAuth = isNativeOAuthRedirect(); if (isTauriAuth) { - // Handle Tauri redirect - const accessToken = localStorage.getItem("access_token") || ""; - const refreshToken = localStorage.getItem("refresh_token"); - - let deepLinkUrl = `cloud.opensecret.maple://auth?access_token=${encodeURIComponent(accessToken)}`; - - if (refreshToken) { - deepLinkUrl += `&refresh_token=${encodeURIComponent(refreshToken)}`; + const nativeOAuthAttemptId = readTransportV2DesktopOAuthAttempt(); + const nativeSessionId = readTransportV2DesktopOAuthSession(); + if (!nativeOAuthAttemptId || !nativeSessionId) { + throw new Error("Desktop authentication state is missing or expired; please restart login"); } + const { grant } = await mintNativeHandoffGrant(nativeSessionId, nativeOAuthAttemptId); const selectedPlan = sessionStorage.getItem("selected_plan"); sessionStorage.removeItem("selected_plan"); @@ -60,9 +66,12 @@ function OAuthCallback() { sessionStorage.removeItem("post_auth_redirect"); const safePostAuthRedirect = getSafeInternalRedirect(postAuthRedirect); - if (!selectedPlan && safePostAuthRedirect) { - deepLinkUrl += `&next=${encodeURIComponent(safePostAuthRedirect)}`; - } + const deepLinkUrl = buildTransportV2NativeAuthDeepLink( + grant, + nativeSessionId, + !selectedPlan ? safePostAuthRedirect : null + ); + clearDesktopOAuthTransport(); // Store the URL in state so we can show a manual open button as fallback setNativeRedirectUrl(deepLinkUrl); @@ -95,7 +104,7 @@ function OAuthCallback() { navigate({ to: "/" }); } }, 2000); - }; + }, [mintNativeHandoffGrant, navigate, router]); const handleAuthError = (error: unknown) => { console.error(`Authentication callback error:`, error); @@ -159,7 +168,7 @@ function OAuthCallback() { } // Handle the successful authentication (redirect) - handleSuccessfulAuth(); + await handleSuccessfulAuth(); } catch (error) { // Handle authentication error handleAuthError(error); @@ -173,7 +182,13 @@ function OAuthCallback() { }; processCallback(); - }, [handleGitHubCallback, handleGoogleCallback, handleAppleCallback, navigate, provider, router]); + }, [ + handleAppleCallback, + handleGitHubCallback, + handleGoogleCallback, + handleSuccessfulAuth, + provider + ]); // After auth completes for a native app flow, show a button to open the app if (nativeRedirectUrl) { @@ -195,7 +210,7 @@ function OAuthCallback() { } // If this is a Tauri app auth flow (desktop or mobile), show processing UI - if (localStorage.getItem("redirect-to-native") === "true") { + if (isNativeOAuthRedirect()) { return ( diff --git a/frontend/src/routes/desktop-auth.tsx b/frontend/src/routes/desktop-auth.tsx index e4661057a..5c8dcdb1e 100644 --- a/frontend/src/routes/desktop-auth.tsx +++ b/frontend/src/routes/desktop-auth.tsx @@ -5,10 +5,20 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Loader2 } from "lucide-react"; import { AppleAuthProvider } from "@/components/AppleAuthProvider"; import { getSafeInternalRedirect } from "@/utils/internalRedirect"; +import { + claimTransportV2DesktopOAuthInitiation, + markTransportV2DesktopOAuth, + readTransportV2DesktopOAuthAttempt, + readTransportV2DesktopOAuthAttemptFromFragment, + readTransportV2DesktopOAuthSession +} from "@/services/desktopOAuthTransport"; +import { isNativeOAuthSessionId } from "@/services/nativeOAuthAttempt"; // Define the search parameters interface interface DesktopAuthSearchParams { provider: string; + transport: "v2"; + native_session_id: string; selected_plan?: string; next?: string; } @@ -22,8 +32,16 @@ export const Route = createFileRoute("/desktop-auth")({ if (provider !== "github" && provider !== "google" && provider !== "apple") { throw new Error(`Unsupported provider: ${provider}`); } + if (search.transport !== "v2") { + throw new Error("Unsupported desktop authentication transport"); + } + if (!isNativeOAuthSessionId(search.native_session_id)) { + throw new Error("Desktop authentication native session is missing or invalid"); + } return { provider, + transport: "v2", + native_session_id: search.native_session_id, selected_plan: typeof search.selected_plan === "string" ? search.selected_plan : undefined, next: getSafeInternalRedirect(search.next) }; @@ -33,15 +51,35 @@ export const Route = createFileRoute("/desktop-auth")({ function DesktopAuth() { // Use the typed search params const search = Route.useSearch(); - const { provider, selected_plan, next } = search; + const { provider, selected_plan, next, native_session_id } = search; const navigate = useNavigate(); const os = useOpenSecret(); useEffect(() => { const initiateAuth = async () => { try { - // Store the flag to indicate this is a Tauri app auth flow (desktop or mobile) - localStorage.setItem("redirect-to-native", "true"); + const fragmentAttempt = readTransportV2DesktopOAuthAttemptFromFragment( + window.location.hash + ); + const storedAttempt = + readTransportV2DesktopOAuthSession() === native_session_id + ? readTransportV2DesktopOAuthAttempt() + : null; + const nativeOAuthAttempt = fragmentAttempt ?? storedAttempt; + if (!nativeOAuthAttempt) { + throw new Error("Desktop authentication state is missing or invalid"); + } + + // Preserve the v2 transport marker and native handoff state across the + // provider's full-page redirect in this browser tab. + markTransportV2DesktopOAuth(nativeOAuthAttempt, native_session_id); + if (fragmentAttempt) { + window.history.replaceState( + null, + "", + `${window.location.pathname}${window.location.search}` + ); + } // Store selected plan if present sessionStorage.removeItem("selected_plan"); @@ -60,6 +98,13 @@ function DesktopAuth() { return; } + // React context replacement, StrictMode, or a route remount may rerun + // this effect. The hosted tab may initiate a given native attempt only + // once; a retry starts from Maple and therefore receives new state. + if (!claimTransportV2DesktopOAuthInitiation(nativeOAuthAttempt)) { + return; + } + // Initiate appropriate OAuth flow for GitHub and Google let auth_url; if (provider === "github") { @@ -82,7 +127,7 @@ function DesktopAuth() { }; initiateAuth(); - }, [os, provider, selected_plan, next, navigate]); + }, [os, provider, selected_plan, next, native_session_id, navigate]); // Special handling for Apple OAuth - use popup instead of redirect if (provider === "apple") { diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx index 1e1b841ee..087c42f6b 100644 --- a/frontend/src/routes/login.tsx +++ b/frontend/src/routes/login.tsx @@ -21,6 +21,7 @@ import { appUrl } from "@/config/domains"; import { useRouteMeta } from "@/utils/routeMeta"; import { getSafeInternalRedirect, navigateToSafeInternalRedirect } from "@/utils/internalRedirect"; import { beginNativeOAuthAttempt, cancelNativeOAuthAttempt } from "@/services/nativeOAuthAttempt"; +import { buildTransportV2DesktopAuthUrl } from "@/services/desktopOAuthTransport"; type LoginSearchParams = { next?: string; @@ -126,29 +127,22 @@ function LoginPage() { console.log("[OAuth] Using", isTauriEnv ? "Tauri" : "web", "flow"); if (isTauriEnv) { - // For Tauri (desktop or mobile), redirect to the web app's desktop-auth route - let desktopAuthUrl = "https://trymaple.ai/desktop-auth?provider=github"; - - // If there's a selected plan, add it to the URL - if (selected_plan) { - desktopAuthUrl += `&selected_plan=${encodeURIComponent(selected_plan)}`; - } - - // If there's a redemption code, add it to the URL - if (code) { - desktopAuthUrl += `&code=${encodeURIComponent(code)}`; - } - - if (next) { - desktopAuthUrl += `&next=${encodeURIComponent(next)}`; - } + const { nativeOAuthAttempt: nativeOAuthAttemptId, sessionId } = + await beginNativeOAuthAttempt(import.meta.env.VITE_OPEN_SECRET_API_URL); + const desktopAuthUrl = buildTransportV2DesktopAuthUrl({ + provider: "github", + nativeOAuthAttemptId, + nativeSessionId: sessionId, + selectedPlan: selected_plan, + code, + next + }); // Use the opener plugin by directly invoking the command // This works for both desktop and mobile (iOS/Android) - console.log("[OAuth] Opening URL in external browser:", desktopAuthUrl); - const nativeOAuthAttemptId = beginNativeOAuthAttempt(); + console.log("[OAuth] Opening authentication page in external browser"); invoke("plugin:opener|open_url", { url: desktopAuthUrl }).catch((error: Error) => { - cancelNativeOAuthAttempt(nativeOAuthAttemptId); + void cancelNativeOAuthAttempt(nativeOAuthAttemptId).catch(() => undefined); console.error("[OAuth] Failed to open external browser:", error); setError("Failed to open authentication page in browser"); }); @@ -179,29 +173,22 @@ function LoginPage() { console.log("[OAuth] Using", isTauriEnv ? "Tauri" : "web", "flow"); if (isTauriEnv) { - // For Tauri (desktop or mobile), redirect to the web app's desktop-auth route - let desktopAuthUrl = "https://trymaple.ai/desktop-auth?provider=google"; - - // If there's a selected plan, add it to the URL - if (selected_plan) { - desktopAuthUrl += `&selected_plan=${encodeURIComponent(selected_plan)}`; - } - - // If there's a redemption code, add it to the URL - if (code) { - desktopAuthUrl += `&code=${encodeURIComponent(code)}`; - } - - if (next) { - desktopAuthUrl += `&next=${encodeURIComponent(next)}`; - } + const { nativeOAuthAttempt: nativeOAuthAttemptId, sessionId } = + await beginNativeOAuthAttempt(import.meta.env.VITE_OPEN_SECRET_API_URL); + const desktopAuthUrl = buildTransportV2DesktopAuthUrl({ + provider: "google", + nativeOAuthAttemptId, + nativeSessionId: sessionId, + selectedPlan: selected_plan, + code, + next + }); // Use the opener plugin by directly invoking the command // This works for both desktop and mobile (iOS/Android) - console.log("[OAuth] Opening URL in external browser:", desktopAuthUrl); - const nativeOAuthAttemptId = beginNativeOAuthAttempt(); + console.log("[OAuth] Opening authentication page in external browser"); invoke("plugin:opener|open_url", { url: desktopAuthUrl }).catch((error: Error) => { - cancelNativeOAuthAttempt(nativeOAuthAttemptId); + void cancelNativeOAuthAttempt(nativeOAuthAttemptId).catch(() => undefined); console.error("[OAuth] Failed to open external browser:", error); setError("Failed to open authentication page in browser"); }); @@ -359,28 +346,21 @@ function LoginPage() { setError(errorMessage); } } else if (isTauriEnv) { - // For Tauri desktop and Android, redirect to the web app's desktop-auth route - let desktopAuthUrl = "https://trymaple.ai/desktop-auth?provider=apple"; - - // If there's a selected plan, add it to the URL - if (selected_plan) { - desktopAuthUrl += `&selected_plan=${encodeURIComponent(selected_plan)}`; - } - - // If there's a redemption code, add it to the URL - if (code) { - desktopAuthUrl += `&code=${encodeURIComponent(code)}`; - } - - if (next) { - desktopAuthUrl += `&next=${encodeURIComponent(next)}`; - } + const { nativeOAuthAttempt: nativeOAuthAttemptId, sessionId } = + await beginNativeOAuthAttempt(import.meta.env.VITE_OPEN_SECRET_API_URL); + const desktopAuthUrl = buildTransportV2DesktopAuthUrl({ + provider: "apple", + nativeOAuthAttemptId, + nativeSessionId: sessionId, + selectedPlan: selected_plan, + code, + next + }); // Use the opener plugin by directly invoking the command - console.log("[OAuth] Opening URL in external browser:", desktopAuthUrl); - const nativeOAuthAttemptId = beginNativeOAuthAttempt(); + console.log("[OAuth] Opening authentication page in external browser"); invoke("plugin:opener|open_url", { url: desktopAuthUrl }).catch((error: Error) => { - cancelNativeOAuthAttempt(nativeOAuthAttemptId); + void cancelNativeOAuthAttempt(nativeOAuthAttemptId).catch(() => undefined); console.error("[OAuth] Failed to open external browser:", error); setError("Failed to open authentication page in browser"); }); diff --git a/frontend/src/routes/signup.tsx b/frontend/src/routes/signup.tsx index c6d7ae7b9..a30619861 100644 --- a/frontend/src/routes/signup.tsx +++ b/frontend/src/routes/signup.tsx @@ -25,6 +25,7 @@ import { useRouteMeta } from "@/utils/routeMeta"; import { getSafeInternalRedirect, navigateToSafeInternalRedirect } from "@/utils/internalRedirect"; import { shouldRedirectAuthenticatedSignup } from "@/utils/signupRedirect"; import { beginNativeOAuthAttempt, cancelNativeOAuthAttempt } from "@/services/nativeOAuthAttempt"; +import { buildTransportV2DesktopAuthUrl } from "@/services/desktopOAuthTransport"; type SignupSearchParams = { next?: string; @@ -156,29 +157,22 @@ function SignupPage() { console.log("[OAuth] Using", isTauriEnv ? "Tauri" : "web", "flow"); if (isTauriEnv) { - // For Tauri (desktop or mobile), redirect to the web app's desktop-auth route - let desktopAuthUrl = "https://trymaple.ai/desktop-auth?provider=github"; - - // If there's a selected plan, add it to the URL - if (selected_plan) { - desktopAuthUrl += `&selected_plan=${encodeURIComponent(selected_plan)}`; - } - - // If there's a redemption code, add it to the URL - if (code) { - desktopAuthUrl += `&code=${encodeURIComponent(code)}`; - } - - if (next) { - desktopAuthUrl += `&next=${encodeURIComponent(next)}`; - } + const { nativeOAuthAttempt: nativeOAuthAttemptId, sessionId } = + await beginNativeOAuthAttempt(import.meta.env.VITE_OPEN_SECRET_API_URL); + const desktopAuthUrl = buildTransportV2DesktopAuthUrl({ + provider: "github", + nativeOAuthAttemptId, + nativeSessionId: sessionId, + selectedPlan: selected_plan, + code, + next + }); // Use the opener plugin by directly invoking the command // This works for both desktop and mobile (iOS/Android) - console.log("[OAuth] Opening URL in external browser:", desktopAuthUrl); - const nativeOAuthAttemptId = beginNativeOAuthAttempt(); + console.log("[OAuth] Opening authentication page in external browser"); invoke("plugin:opener|open_url", { url: desktopAuthUrl }).catch((error: Error) => { - cancelNativeOAuthAttempt(nativeOAuthAttemptId); + void cancelNativeOAuthAttempt(nativeOAuthAttemptId).catch(() => undefined); console.error("[OAuth] Failed to open external browser:", error); setError("Failed to open authentication page in browser"); }); @@ -209,29 +203,22 @@ function SignupPage() { console.log("[OAuth] Using", isTauriEnv ? "Tauri" : "web", "flow"); if (isTauriEnv) { - // For Tauri (desktop or mobile), redirect to the web app's desktop-auth route - let desktopAuthUrl = "https://trymaple.ai/desktop-auth?provider=google"; - - // If there's a selected plan, add it to the URL - if (selected_plan) { - desktopAuthUrl += `&selected_plan=${encodeURIComponent(selected_plan)}`; - } - - // If there's a redemption code, add it to the URL - if (code) { - desktopAuthUrl += `&code=${encodeURIComponent(code)}`; - } - - if (next) { - desktopAuthUrl += `&next=${encodeURIComponent(next)}`; - } + const { nativeOAuthAttempt: nativeOAuthAttemptId, sessionId } = + await beginNativeOAuthAttempt(import.meta.env.VITE_OPEN_SECRET_API_URL); + const desktopAuthUrl = buildTransportV2DesktopAuthUrl({ + provider: "google", + nativeOAuthAttemptId, + nativeSessionId: sessionId, + selectedPlan: selected_plan, + code, + next + }); // Use the opener plugin by directly invoking the command // This works for both desktop and mobile (iOS/Android) - console.log("[OAuth] Opening URL in external browser:", desktopAuthUrl); - const nativeOAuthAttemptId = beginNativeOAuthAttempt(); + console.log("[OAuth] Opening authentication page in external browser"); invoke("plugin:opener|open_url", { url: desktopAuthUrl }).catch((error: Error) => { - cancelNativeOAuthAttempt(nativeOAuthAttemptId); + void cancelNativeOAuthAttempt(nativeOAuthAttemptId).catch(() => undefined); console.error("[OAuth] Failed to open external browser:", error); setError("Failed to open authentication page in browser"); }); @@ -392,28 +379,21 @@ function SignupPage() { setError(errorMessage); } } else if (isTauriEnv) { - // For Tauri desktop and Android, redirect to the web app's desktop-auth route - let desktopAuthUrl = "https://trymaple.ai/desktop-auth?provider=apple"; - - // If there's a selected plan, add it to the URL - if (selected_plan) { - desktopAuthUrl += `&selected_plan=${encodeURIComponent(selected_plan)}`; - } - - // If there's a redemption code, add it to the URL - if (code) { - desktopAuthUrl += `&code=${encodeURIComponent(code)}`; - } - - if (next) { - desktopAuthUrl += `&next=${encodeURIComponent(next)}`; - } + const { nativeOAuthAttempt: nativeOAuthAttemptId, sessionId } = + await beginNativeOAuthAttempt(import.meta.env.VITE_OPEN_SECRET_API_URL); + const desktopAuthUrl = buildTransportV2DesktopAuthUrl({ + provider: "apple", + nativeOAuthAttemptId, + nativeSessionId: sessionId, + selectedPlan: selected_plan, + code, + next + }); // Use the opener plugin by directly invoking the command - console.log("[OAuth] Opening URL in external browser:", desktopAuthUrl); - const nativeOAuthAttemptId = beginNativeOAuthAttempt(); + console.log("[OAuth] Opening authentication page in external browser"); invoke("plugin:opener|open_url", { url: desktopAuthUrl }).catch((error: Error) => { - cancelNativeOAuthAttempt(nativeOAuthAttemptId); + void cancelNativeOAuthAttempt(nativeOAuthAttemptId).catch(() => undefined); console.error("[OAuth] Failed to open external browser:", error); setError("Failed to open authentication page in browser"); }); diff --git a/frontend/src/services/desktopOAuthTransport.test.ts b/frontend/src/services/desktopOAuthTransport.test.ts new file mode 100644 index 000000000..e2d71b884 --- /dev/null +++ b/frontend/src/services/desktopOAuthTransport.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { + buildTransportV2DesktopAuthUrl, + buildTransportV2NativeAuthDeepLink, + claimTransportV2DesktopOAuthInitiation, + clearDesktopOAuthTransport, + markDesktopOAuthTransport, + markTransportV2DesktopOAuth, + readDesktopOAuthTransport, + readTransportV2DesktopOAuthAttempt, + readTransportV2DesktopOAuthSession, + shouldLoadLegacyDesktopOAuth +} from "./desktopOAuthTransport"; + +class MemoryStorage implements Storage { + private readonly values = new Map(); + + get length(): number { + return this.values.size; + } + + clear(): void { + this.values.clear(); + } + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + key(index: number): string | null { + return [...this.values.keys()][index] ?? null; + } + + removeItem(key: string): void { + this.values.delete(key); + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } +} + +function location(pathname: string, search = ""): Pick { + return { pathname, search }; +} + +beforeEach(() => { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: new MemoryStorage(), + writable: true + }); + Object.defineProperty(globalThis, "sessionStorage", { + configurable: true, + value: new MemoryStorage(), + writable: true + }); +}); + +describe("desktop OAuth transport selection", () => { + const nativeOAuthAttemptId = "00000000-0000-4000-8000-000000000001"; + const nativeSessionId = "11111111-2222-3333-4444-555555555555"; + + test("keeps the native attempt out of the HTTPS request and carries it in the fragment", () => { + const authUrl = buildTransportV2DesktopAuthUrl({ + provider: "github", + nativeOAuthAttemptId, + nativeSessionId, + selectedPlan: "pro", + code: "redemption", + next: "/settings" + }); + const parsed = new URL(authUrl); + + expect(parsed.origin).toBe("https://trymaple.ai"); + expect(parsed.pathname).toBe("/desktop-auth"); + expect(parsed.searchParams.get("provider")).toBe("github"); + expect(parsed.searchParams.get("transport")).toBe("v2"); + expect(parsed.searchParams.has("native_oauth_attempt")).toBe(false); + expect(new URLSearchParams(parsed.hash.slice(1)).get("native_oauth_attempt")).toBe( + nativeOAuthAttemptId + ); + expect(parsed.searchParams.get("native_session_id")).toBe(nativeSessionId); + expect(parsed.searchParams.get("selected_plan")).toBe("pro"); + expect(parsed.searchParams.get("code")).toBe("redemption"); + expect(parsed.searchParams.get("next")).toBe("/settings"); + }); + + test("stores only validated v2 attempt state across the hosted redirect", () => { + markTransportV2DesktopOAuth(nativeOAuthAttemptId, nativeSessionId); + + expect(readDesktopOAuthTransport()).toBe("v2"); + expect(readTransportV2DesktopOAuthAttempt()).toBe(nativeOAuthAttemptId); + expect(readTransportV2DesktopOAuthSession()).toBe(nativeSessionId); + expect(() => markTransportV2DesktopOAuth("not-state", nativeSessionId)).toThrow(); + }); + + test("claims GitHub or Google initiation exactly once for one native attempt", () => { + markTransportV2DesktopOAuth(nativeOAuthAttemptId, nativeSessionId); + + expect(claimTransportV2DesktopOAuthInitiation(nativeOAuthAttemptId)).toBe(true); + expect(claimTransportV2DesktopOAuthInitiation(nativeOAuthAttemptId)).toBe(false); + + const nextAttemptId = "00000000-0000-4000-8000-000000000002"; + markTransportV2DesktopOAuth(nextAttemptId, nativeSessionId); + expect(claimTransportV2DesktopOAuthInitiation(nextAttemptId)).toBe(true); + }); + + test("cannot claim initiation for state other than the active hosted attempt", () => { + markTransportV2DesktopOAuth(nativeOAuthAttemptId, nativeSessionId); + + expect(() => + claimTransportV2DesktopOAuthInitiation("00000000-0000-4000-8000-000000000002") + ).toThrow("state changed"); + }); + + test("builds a v2 handoff with only the grant and public session correlation", () => { + const deepLink = buildTransportV2NativeAuthDeepLink( + "header.payload.signature", + nativeSessionId, + "/settings" + ); + const parsed = new URL(deepLink); + + expect(parsed.searchParams.get("handoff_grant")).toBe("header.payload.signature"); + expect(parsed.searchParams.has("auth_bundle")).toBe(false); + expect(parsed.searchParams.get("native_session_id")).toBe(nativeSessionId); + expect(parsed.searchParams.has("native_oauth_attempt")).toBe(false); + expect(parsed.searchParams.get("next")).toBe("/settings"); + expect(parsed.searchParams.has("access_token")).toBe(false); + expect(parsed.searchParams.has("refresh_token")).toBe(false); + }); + + test("keeps an unversioned desktop-auth request on the published v1 bridge", () => { + expect(shouldLoadLegacyDesktopOAuth(location("/desktop-auth", "?provider=github"))).toBe(true); + }); + + test("routes explicit v2 and invalid selectors away from the legacy bundle", () => { + expect( + shouldLoadLegacyDesktopOAuth(location("/desktop-auth", "?provider=github&transport=v2")) + ).toBe(false); + expect( + shouldLoadLegacyDesktopOAuth(location("/desktop-auth", "?provider=github&transport=invalid")) + ).toBe(false); + }); + + test("preserves callbacks from already-running old-client flows", () => { + localStorage.setItem("redirect-to-native", "true"); + expect(shouldLoadLegacyDesktopOAuth(location("/auth/github/callback"))).toBe(true); + }); + + test("never loads v1 for an explicitly marked v2 callback", () => { + markDesktopOAuthTransport("v2"); + expect(readDesktopOAuthTransport()).toBe("v2"); + expect(shouldLoadLegacyDesktopOAuth(location("/auth/google/callback"))).toBe(false); + }); + + test("does not load the legacy bridge for ordinary web callbacks", () => { + expect(shouldLoadLegacyDesktopOAuth(location("/auth/apple/callback"))).toBe(false); + }); + + test("clears both compatibility markers after a completed handoff", () => { + markDesktopOAuthTransport("v1"); + clearDesktopOAuthTransport(); + expect(readDesktopOAuthTransport()).toBeNull(); + expect(readTransportV2DesktopOAuthAttempt()).toBeNull(); + expect(readTransportV2DesktopOAuthSession()).toBeNull(); + expect(localStorage.getItem("redirect-to-native")).toBeNull(); + }); +}); diff --git a/frontend/src/services/desktopOAuthTransport.ts b/frontend/src/services/desktopOAuthTransport.ts new file mode 100644 index 000000000..62d552ea8 --- /dev/null +++ b/frontend/src/services/desktopOAuthTransport.ts @@ -0,0 +1,162 @@ +import { isNativeOAuthAttemptId, isNativeOAuthSessionId } from "./nativeOAuthAttempt"; + +export type DesktopOAuthTransport = "v1" | "v2"; +export type DesktopOAuthProvider = "github" | "google" | "apple"; + +const DESKTOP_OAUTH_TRANSPORT_KEY = "maple_desktop_oauth_transport_v1"; +const REDIRECT_TO_NATIVE_KEY = "redirect-to-native"; +const TRANSPORT_V2_NATIVE_ATTEMPT_KEY = "maple_desktop_oauth_native_attempt_v2"; +const TRANSPORT_V2_NATIVE_SESSION_KEY = "maple_desktop_oauth_native_session_v2"; +const TRANSPORT_V2_INITIATION_CLAIM_KEY = "maple_desktop_oauth_initiation_claim_v2"; +export const TRANSPORT_V2_NATIVE_ATTEMPT_QUERY = "native_oauth_attempt"; +export const TRANSPORT_V2_NATIVE_SESSION_QUERY = "native_session_id"; + +interface TransportV2DesktopAuthUrlOptions { + provider: DesktopOAuthProvider; + nativeOAuthAttemptId: string; + nativeSessionId: string; + selectedPlan?: string; + code?: string; + next?: string; +} + +export function buildTransportV2DesktopAuthUrl({ + provider, + nativeOAuthAttemptId, + nativeSessionId, + selectedPlan, + code, + next +}: TransportV2DesktopAuthUrlOptions): string { + if (!isNativeOAuthAttemptId(nativeOAuthAttemptId)) { + throw new Error("Cannot start desktop authentication without valid state"); + } + if (!isNativeOAuthSessionId(nativeSessionId)) { + throw new Error("Cannot start desktop authentication without a valid native session"); + } + + const url = new URL("https://trymaple.ai/desktop-auth"); + url.searchParams.set("provider", provider); + url.searchParams.set("transport", "v2"); + url.searchParams.set(TRANSPORT_V2_NATIVE_SESSION_QUERY, nativeSessionId); + if (selectedPlan) url.searchParams.set("selected_plan", selectedPlan); + if (code) url.searchParams.set("code", code); + if (next) url.searchParams.set("next", next); + url.hash = new URLSearchParams({ + [TRANSPORT_V2_NATIVE_ATTEMPT_QUERY]: nativeOAuthAttemptId + }).toString(); + return url.toString(); +} + +export function readTransportV2DesktopOAuthAttemptFromFragment(hash: string): string | null { + const fragment = hash.startsWith("#") ? hash.slice(1) : hash; + const attemptId = new URLSearchParams(fragment).get(TRANSPORT_V2_NATIVE_ATTEMPT_QUERY); + return isNativeOAuthAttemptId(attemptId) ? attemptId : null; +} + +export function markDesktopOAuthTransport(transport: DesktopOAuthTransport): void { + localStorage.setItem(DESKTOP_OAUTH_TRANSPORT_KEY, transport); + localStorage.setItem(REDIRECT_TO_NATIVE_KEY, "true"); +} + +export function markTransportV2DesktopOAuth( + nativeOAuthAttemptId: string, + nativeSessionId: string +): void { + if (!isNativeOAuthAttemptId(nativeOAuthAttemptId)) { + throw new Error("Desktop authentication state is missing or invalid"); + } + if (!isNativeOAuthSessionId(nativeSessionId)) { + throw new Error("Desktop authentication native session is missing or invalid"); + } + sessionStorage.setItem(TRANSPORT_V2_NATIVE_ATTEMPT_KEY, nativeOAuthAttemptId); + sessionStorage.setItem(TRANSPORT_V2_NATIVE_SESSION_KEY, nativeSessionId); + markDesktopOAuthTransport("v2"); +} + +export function readTransportV2DesktopOAuthAttempt(): string | null { + const attemptId = sessionStorage.getItem(TRANSPORT_V2_NATIVE_ATTEMPT_KEY); + if (isNativeOAuthAttemptId(attemptId)) return attemptId; + sessionStorage.removeItem(TRANSPORT_V2_NATIVE_ATTEMPT_KEY); + return null; +} + +export function readTransportV2DesktopOAuthSession(): string | null { + const sessionId = sessionStorage.getItem(TRANSPORT_V2_NATIVE_SESSION_KEY); + if (isNativeOAuthSessionId(sessionId)) return sessionId; + sessionStorage.removeItem(TRANSPORT_V2_NATIVE_SESSION_KEY); + return null; +} + +export function claimTransportV2DesktopOAuthInitiation(nativeOAuthAttemptId: string): boolean { + if (!isNativeOAuthAttemptId(nativeOAuthAttemptId)) { + throw new Error("Desktop authentication state is missing or invalid"); + } + if (readTransportV2DesktopOAuthAttempt() !== nativeOAuthAttemptId) { + throw new Error("Desktop authentication state changed before initiation"); + } + + if (sessionStorage.getItem(TRANSPORT_V2_INITIATION_CLAIM_KEY) === nativeOAuthAttemptId) { + return false; + } + sessionStorage.setItem(TRANSPORT_V2_INITIATION_CLAIM_KEY, nativeOAuthAttemptId); + return true; +} + +export function readDesktopOAuthTransport(): DesktopOAuthTransport | null { + const transport = localStorage.getItem(DESKTOP_OAUTH_TRANSPORT_KEY); + return transport === "v1" || transport === "v2" ? transport : null; +} + +export function clearDesktopOAuthTransport(): void { + localStorage.removeItem(DESKTOP_OAUTH_TRANSPORT_KEY); + localStorage.removeItem(REDIRECT_TO_NATIVE_KEY); + sessionStorage.removeItem(TRANSPORT_V2_NATIVE_ATTEMPT_KEY); + sessionStorage.removeItem(TRANSPORT_V2_NATIVE_SESSION_KEY); + sessionStorage.removeItem(TRANSPORT_V2_INITIATION_CLAIM_KEY); +} + +export function isNativeOAuthRedirect(): boolean { + return localStorage.getItem(REDIRECT_TO_NATIVE_KEY) === "true"; +} + +export function buildTransportV2NativeAuthDeepLink( + handoffGrant: string, + nativeSessionId: string, + next?: string | null +): string { + if (!handoffGrant.trim()) { + throw new Error("The desktop authentication grant is missing"); + } + if (!isNativeOAuthSessionId(nativeSessionId)) { + throw new Error("The desktop authentication native session is missing or invalid"); + } + const query = new URLSearchParams({ + handoff_grant: handoffGrant, + [TRANSPORT_V2_NATIVE_SESSION_QUERY]: nativeSessionId + }); + if (next) query.set("next", next); + return `cloud.opensecret.maple://auth?${query.toString()}`; +} + +export function shouldLoadLegacyDesktopOAuth( + location: Pick +): boolean { + if (location.pathname === "/desktop-auth") { + // Released clients did not send a transport selector. New clients always + // opt in explicitly, so removing `transport=v2` cannot downgrade the app + // that eventually receives the incompatible credential shape. + return new URLSearchParams(location.search).get("transport") === null; + } + + if (!/^\/auth\/(github|google|apple)\/callback$/.test(location.pathname)) { + return false; + } + + if (!isNativeOAuthRedirect()) return false; + + const transport = readDesktopOAuthTransport(); + // The missing marker is the compatibility case for an already-running OAuth + // attempt started by the previously deployed web app. + return transport === "v1" || transport === null; +} diff --git a/frontend/src/services/mapleApiAuthService.test.ts b/frontend/src/services/mapleApiAuthService.test.ts index 01f7e34bf..2261ed28e 100644 --- a/frontend/src/services/mapleApiAuthService.test.ts +++ b/frontend/src/services/mapleApiAuthService.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; import { MapleApiAuthService, - type BrowserTokenPair, type MapleApiAuthBridge, type MapleApiAuthChanged, type MapleApiAuthMetadata, @@ -17,10 +16,7 @@ function deferred(): { promise: Promise; resolve: () => void } { } class FakeAuthBridge implements MapleApiAuthBridge { - browserTokens: BrowserTokenPair = { - accessToken: "access-one", - refreshToken: "refresh-one" - }; + browserBundle = "browser-bundle-one"; metadata: MapleApiAuthMetadata | null = null; nativeSnapshot: MapleApiAuthSnapshot | null = null; setCalls = 0; @@ -40,12 +36,12 @@ class FakeAuthBridge implements MapleApiAuthBridge { return "https://enclave.trymaple.ai"; } - readTokens(): BrowserTokenPair { - return { ...this.browserTokens }; + async exportAuthBundle(): Promise { + return this.browserBundle; } - writeTokens(tokens: BrowserTokenPair): void { - this.browserTokens = { ...tokens }; + async importAuthBundle(bundle: string): Promise { + this.browserBundle = bundle; } readMetadata(): MapleApiAuthMetadata | null { @@ -62,19 +58,14 @@ class FakeAuthBridge implements MapleApiAuthBridge { this.commandOrder.push("set:start"); const request = args.request as { userId: string; - accessToken: string; - refreshToken: string | null; + authBundle: string; }; await this.setHook?.(); const prior = this.nativeSnapshot; - const unchanged = - prior?.userId === request.userId && - prior.accessToken === request.accessToken && - (prior.refreshToken || null) === request.refreshToken; + const unchanged = prior?.userId === request.userId && prior.authBundle === request.authBundle; this.nativeSnapshot = { userId: request.userId, - accessToken: request.accessToken, - refreshToken: request.refreshToken, + authBundle: request.authBundle, nativeInstanceId: "native-instance-1", revision: unchanged ? prior.revision : (prior?.revision ?? 0) + 1 }; @@ -110,12 +101,11 @@ class FakeAuthBridge implements MapleApiAuthBridge { await this.handler(event); } - setNativeRefresh(tokens: BrowserTokenPair, revision: number): void { + setNativeRefresh(authBundle: string, revision: number): void { if (!this.nativeSnapshot) throw new Error("native auth missing"); this.nativeSnapshot = { userId: this.nativeSnapshot.userId, - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, + authBundle, nativeInstanceId: this.nativeSnapshot.nativeInstanceId, revision }; @@ -123,7 +113,7 @@ class FakeAuthBridge implements MapleApiAuthBridge { } describe("MapleApiAuthService", () => { - test("installs once and only pushes browser credentials after they change", async () => { + test("installs once and only pushes the browser bundle after it changes", async () => { const bridge = new FakeAuthBridge(); const service = new MapleApiAuthService(bridge); @@ -131,31 +121,22 @@ describe("MapleApiAuthService", () => { await service.sync("user-a"); expect(bridge.setCalls).toBe(1); - bridge.browserTokens = { - accessToken: "browser-refreshed", - refreshToken: "browser-refresh-token" - }; + bridge.browserBundle = "browser-bundle-refreshed"; await service.sync("user-a"); expect(bridge.setCalls).toBe(2); - expect(bridge.nativeSnapshot?.accessToken).toBe("browser-refreshed"); + expect(bridge.nativeSnapshot?.authBundle).toBe("browser-bundle-refreshed"); expect(bridge.metadata?.nativeRevision).toBe(2); }); - test("reconciles an SDK-refreshed token pair back to the browser", async () => { + test("reconciles an SDK-refreshed opaque bundle back to the browser", async () => { const bridge = new FakeAuthBridge(); const service = new MapleApiAuthService(bridge); await service.activate("user-a"); - bridge.setNativeRefresh( - { accessToken: "native-refreshed", refreshToken: "native-refresh-token" }, - 2 - ); - await bridge.emit({ userId: "user-a", revision: 2 }); + bridge.setNativeRefresh("native-bundle-refreshed", 2); + await bridge.emit({ userId: "user-a", revision: 2, authenticated: true }); - expect(bridge.browserTokens).toEqual({ - accessToken: "native-refreshed", - refreshToken: "native-refresh-token" - }); + expect(bridge.browserBundle).toBe("native-bundle-refreshed"); expect(bridge.metadata?.nativeRevision).toBe(2); }); @@ -164,17 +145,11 @@ describe("MapleApiAuthService", () => { const firstService = new MapleApiAuthService(bridge); await firstService.activate("user-a"); - bridge.setNativeRefresh( - { accessToken: "native-refreshed", refreshToken: "native-refresh-token" }, - 2 - ); + bridge.setNativeRefresh("native-bundle-refreshed", 2); const reloadedService = new MapleApiAuthService(bridge); await reloadedService.activate("user-a"); - expect(bridge.browserTokens).toEqual({ - accessToken: "native-refreshed", - refreshToken: "native-refresh-token" - }); + expect(bridge.browserBundle).toBe("native-bundle-refreshed"); expect(bridge.setCalls).toBe(1); expect(bridge.metadata?.nativeRevision).toBe(2); }); @@ -184,35 +159,29 @@ describe("MapleApiAuthService", () => { const service = new MapleApiAuthService(bridge); await service.activate("user-a"); - bridge.browserTokens = { - accessToken: "browser-won", - refreshToken: "browser-won-refresh" - }; - bridge.setNativeRefresh({ accessToken: "late-native", refreshToken: "late-native-refresh" }, 2); - await bridge.emit({ userId: "user-a", revision: 2 }); + bridge.browserBundle = "browser-bundle-won"; + bridge.setNativeRefresh("late-native-bundle", 2); + await bridge.emit({ userId: "user-a", revision: 2, authenticated: true }); expect(bridge.setCalls).toBe(2); - expect(bridge.browserTokens.accessToken).toBe("browser-won"); - expect(bridge.nativeSnapshot?.accessToken).toBe("browser-won"); + expect(bridge.browserBundle).toBe("browser-bundle-won"); + expect(bridge.nativeSnapshot?.authBundle).toBe("browser-bundle-won"); }); test("a browser refresh during get_auth is reinstalled instead of overwritten", async () => { const bridge = new FakeAuthBridge(); const service = new MapleApiAuthService(bridge); await service.activate("user-a"); - bridge.setNativeRefresh({ accessToken: "native-late", refreshToken: "native-late-refresh" }, 2); + bridge.setNativeRefresh("native-bundle-late", 2); bridge.getHook = async () => { bridge.getHook = null; - bridge.browserTokens = { - accessToken: "browser-new", - refreshToken: "browser-new-refresh" - }; + bridge.browserBundle = "browser-bundle-new"; }; - await bridge.emit({ userId: "user-a", revision: 2 }); + await bridge.emit({ userId: "user-a", revision: 2, authenticated: true }); - expect(bridge.browserTokens.accessToken).toBe("browser-new"); - expect(bridge.nativeSnapshot?.accessToken).toBe("browser-new"); + expect(bridge.browserBundle).toBe("browser-bundle-new"); + expect(bridge.nativeSnapshot?.authBundle).toBe("browser-bundle-new"); expect(bridge.setCalls).toBe(2); }); @@ -220,22 +189,16 @@ describe("MapleApiAuthService", () => { const bridge = new FakeAuthBridge(); const service = new MapleApiAuthService(bridge); await service.activate("user-a"); - bridge.browserTokens = { - accessToken: "browser-second", - refreshToken: "browser-second-refresh" - }; + bridge.browserBundle = "browser-bundle-second"; bridge.setHook = async () => { bridge.setHook = null; - bridge.browserTokens = { - accessToken: "browser-third", - refreshToken: "browser-third-refresh" - }; + bridge.browserBundle = "browser-bundle-third"; }; await service.sync("user-a"); expect(bridge.setCalls).toBe(3); - expect(bridge.nativeSnapshot?.accessToken).toBe("browser-third"); + expect(bridge.nativeSnapshot?.authBundle).toBe("browser-bundle-third"); }); test("serialized clear cannot be undone by a delayed credential install", async () => { @@ -259,7 +222,7 @@ describe("MapleApiAuthService", () => { expect(bridge.metadata).toBeNull(); bridge.setHook = null; - bridge.browserTokens = { accessToken: "account-b", refreshToken: "account-b-refresh" }; + bridge.browserBundle = "account-b-bundle"; await service.activate("user-b"); expect(bridge.nativeSnapshot?.userId).toBe("user-b"); }); @@ -268,13 +231,13 @@ describe("MapleApiAuthService", () => { const bridge = new FakeAuthBridge(); const service = new MapleApiAuthService(bridge); await service.activate("user-a"); - const original = { ...bridge.browserTokens }; + const original = bridge.browserBundle; await service.clear("user-a"); - await bridge.emit({ userId: "user-a", revision: 2 }); + await bridge.emit({ userId: "user-a", revision: 2, authenticated: true }); expect(bridge.clearCalls).toBe(1); - expect(bridge.browserTokens).toEqual(original); + expect(bridge.browserBundle).toBe(original); expect(bridge.nativeSnapshot).toBeNull(); }); @@ -288,4 +251,50 @@ describe("MapleApiAuthService", () => { expect(bridge.nativeSnapshot?.userId).toBe("user-a"); }); + + test("native credential rejection clears matching auth and notifies UI lifecycle", async () => { + const bridge = new FakeAuthBridge(); + const service = new MapleApiAuthService(bridge); + const invalidated: string[] = []; + service.subscribeInvalidation(({ userId }) => invalidated.push(userId)); + await service.activate("user-a"); + + await bridge.emit({ userId: "user-a", revision: 2, authenticated: false }); + + expect(bridge.clearCalls).toBe(1); + expect(bridge.nativeSnapshot).toBeNull(); + expect(bridge.metadata).toBeNull(); + expect(invalidated).toEqual(["user-a"]); + await expect(service.sync("user-a")).rejects.toThrow("authentication changed"); + }); + + test("an invalidation for another account cannot clear the active lifecycle", async () => { + const bridge = new FakeAuthBridge(); + const service = new MapleApiAuthService(bridge); + const invalidated: string[] = []; + service.subscribeInvalidation(({ userId }) => invalidated.push(userId)); + await service.activate("user-a"); + + await bridge.emit({ userId: "user-b", revision: 2, authenticated: false }); + + expect(bridge.clearCalls).toBe(0); + expect(bridge.nativeSnapshot?.userId).toBe("user-a"); + expect(invalidated).toEqual([]); + }); + + test("native rejection cannot clear a newer browser credential generation", async () => { + const bridge = new FakeAuthBridge(); + const service = new MapleApiAuthService(bridge); + const invalidated: string[] = []; + service.subscribeInvalidation(({ userId }) => invalidated.push(userId)); + await service.activate("user-a"); + bridge.browserBundle = "browser-bundle-newer"; + + await bridge.emit({ userId: "user-a", revision: 2, authenticated: false }); + + expect(bridge.clearCalls).toBe(0); + expect(bridge.setCalls).toBe(2); + expect(bridge.nativeSnapshot?.authBundle).toBe("browser-bundle-newer"); + expect(invalidated).toEqual([]); + }); }); diff --git a/frontend/src/services/mapleApiAuthService.ts b/frontend/src/services/mapleApiAuthService.ts index 84f7e8d26..20f2a7ff0 100644 --- a/frontend/src/services/mapleApiAuthService.ts +++ b/frontend/src/services/mapleApiAuthService.ts @@ -1,9 +1,9 @@ import { isTauriDesktop } from "@/utils/platform"; +import { exportTransportV2AuthBundle, importTransportV2AuthBundle } from "@opensecret/react"; export interface MapleApiAuthSnapshot { userId: string; - accessToken: string; - refreshToken?: string | null; + authBundle: string; nativeInstanceId: string; revision: number; } @@ -11,28 +11,30 @@ export interface MapleApiAuthSnapshot { export interface MapleApiAuthChanged { userId: string; revision: number; + authenticated: boolean; } -export interface BrowserTokenPair { - accessToken: string; - refreshToken: string | null; +export interface MapleApiAuthInvalidated { + userId: string; } export interface MapleApiAuthMetadata { userId: string; nativeInstanceId: string; nativeRevision: number; - tokenFingerprint: string; + bundleFingerprint: string; } -interface SyncedAuth extends BrowserTokenPair { +interface SyncedAuth { userId: string; + authBundle: string; nativeInstanceId: string; revision: number; } const AUTH_CHANGED_EVENT = "maple-api-auth-changed"; -const AUTH_METADATA_KEY = "maple_api_auth_sync_v1"; +const AUTH_METADATA_KEY = "maple_api_auth_sync_v2"; +const LEGACY_AUTH_METADATA_KEY = "maple_api_auth_sync_v1"; const MAX_SYNC_ATTEMPTS = 3; function normalizeUserId(userId: string): string { @@ -41,27 +43,8 @@ function normalizeUserId(userId: string): string { return normalized; } -function readBrowserTokens(): BrowserTokenPair { - const accessToken = localStorage.getItem("access_token")?.trim() || ""; - if (!accessToken) { - throw new Error("Maple API access requires a signed-in session"); - } - return { - accessToken, - refreshToken: localStorage.getItem("refresh_token")?.trim() || null - }; -} - -function writeBrowserTokens(tokens: BrowserTokenPair): void { - localStorage.setItem("access_token", tokens.accessToken); - if (tokens.refreshToken) { - localStorage.setItem("refresh_token", tokens.refreshToken); - } else { - localStorage.removeItem("refresh_token"); - } -} - function readBrowserMetadata(): MapleApiAuthMetadata | null { + localStorage.removeItem(LEGACY_AUTH_METADATA_KEY); const encoded = localStorage.getItem(AUTH_METADATA_KEY); if (!encoded) return null; try { @@ -73,8 +56,8 @@ function readBrowserMetadata(): MapleApiAuthMetadata | null { typeof metadata.nativeRevision !== "number" || !Number.isSafeInteger(metadata.nativeRevision) || metadata.nativeRevision < 1 || - typeof metadata.tokenFingerprint !== "string" || - !metadata.tokenFingerprint + typeof metadata.bundleFingerprint !== "string" || + !metadata.bundleFingerprint ) { return null; } @@ -92,16 +75,12 @@ function writeBrowserMetadata(metadata: MapleApiAuthMetadata | null): void { } } -function sameTokens(left: BrowserTokenPair, right: BrowserTokenPair): boolean { - return left.accessToken === right.accessToken && left.refreshToken === right.refreshToken; -} - -// This fingerprint only detects whether another SDK changed the browser pair -// across a WebView reload. Account identity is always verified by the backend -// before native credentials are published. -function tokenFingerprint(tokens: BrowserTokenPair): string { +// This non-cryptographic fingerprint only detects whether another SDK changed +// the opaque browser bundle across a WebView reload. Account identity remains +// authoritative only after the native client validates it with the backend. +function bundleFingerprint(bundle: string): string { let hash = 0xcbf29ce484222325n; - const bytes = new TextEncoder().encode(`${tokens.accessToken}\u0000${tokens.refreshToken ?? ""}`); + const bytes = new TextEncoder().encode(bundle); for (const byte of bytes) { hash ^= BigInt(byte); hash = BigInt.asUintN(64, hash * 0x100000001b3n); @@ -117,8 +96,8 @@ async function invokeNative(command: string, args: Record): export interface MapleApiAuthBridge { isDesktop(): boolean; apiUrl(): string; - readTokens(): BrowserTokenPair; - writeTokens(tokens: BrowserTokenPair): void; + exportAuthBundle(): Promise; + importAuthBundle(bundle: string): Promise; readMetadata(): MapleApiAuthMetadata | null; writeMetadata(metadata: MapleApiAuthMetadata | null): void; invoke(command: string, args: Record): Promise; @@ -128,8 +107,9 @@ export interface MapleApiAuthBridge { const defaultBridge: MapleApiAuthBridge = { isDesktop: isTauriDesktop, apiUrl: () => import.meta.env.VITE_OPEN_SECRET_API_URL, - readTokens: readBrowserTokens, - writeTokens: writeBrowserTokens, + exportAuthBundle: () => exportTransportV2AuthBundle(import.meta.env.VITE_OPEN_SECRET_API_URL), + importAuthBundle: (bundle) => + importTransportV2AuthBundle(bundle, import.meta.env.VITE_OPEN_SECRET_API_URL), readMetadata: readBrowserMetadata, writeMetadata: writeBrowserMetadata, invoke: invokeNative, @@ -146,6 +126,7 @@ export class MapleApiAuthService { private syncedAuth: SyncedAuth | null = null; private listenerPromise: Promise | null = null; private operationTail: Promise = Promise.resolve(); + private readonly invalidationHandlers = new Set<(event: MapleApiAuthInvalidated) => void>(); constructor(private readonly bridge: MapleApiAuthBridge = defaultBridge) {} @@ -189,6 +170,11 @@ export class MapleApiAuthService { }); } + subscribeInvalidation(handler: (event: MapleApiAuthInvalidated) => void): () => void { + this.invalidationHandlers.add(handler); + return () => this.invalidationHandlers.delete(handler); + } + private enqueue(operation: () => Promise): Promise { const result = this.operationTail.then(operation, operation); this.operationTail = result.then( @@ -201,6 +187,10 @@ export class MapleApiAuthService { private async ensureListener(): Promise { if (this.listenerPromise) return await this.listenerPromise; const attempt = this.bridge.listen(async (event) => { + if (!event.authenticated) { + await this.enqueue(() => this.invalidateNativeAuthNow(event.userId)); + return; + } try { await this.enqueue(() => this.reconcileRefreshNow(event)); } catch (error) { @@ -216,6 +206,45 @@ export class MapleApiAuthService { } } + private async invalidateNativeAuthNow(eventUserId: string): Promise { + const userId = this.activeUserId; + if (!userId || normalizeUserId(eventUserId) !== userId) return; + + // A browser refresh can win while an older native generation is failing. + // Reinstall that exact newer opaque bundle instead of signing out the + // matching account. If it cannot validate, fall through to fail closed. + try { + const browserBundle = await this.bridge.exportAuthBundle(); + if (this.syncedAuth?.userId === userId && browserBundle !== this.syncedAuth.authBundle) { + await this.syncNow(userId, true); + return; + } + } catch { + // Browser credentials are absent or unreadable; native cleanup and the + // UI invalidation notification remain mandatory. + } + + try { + await this.bridge.invoke("maple_api_clear_auth", { userId }); + } finally { + if (this.activeUserId === userId) { + this.activeUserId = null; + this.syncedAuth = null; + if (this.bridge.readMetadata()?.userId === userId) { + this.bridge.writeMetadata(null); + } + for (const handler of this.invalidationHandlers) { + try { + handler({ userId }); + } catch { + // One UI observer cannot prevent the remaining account lifecycle + // observers from receiving this fail-closed transition. + } + } + } + } + } + private async reconcileActivationNow(userId: string): Promise { let snapshot: MapleApiAuthSnapshot; try { @@ -227,24 +256,23 @@ export class MapleApiAuthService { this.assertCurrentSnapshot(userId, snapshot); // Read after the native await so a concurrent browser refresh wins unless - // durable metadata proves the native session advanced from this exact pair. - const browserTokens = this.bridge.readTokens(); - const nativeTokens = this.snapshotTokens(snapshot); + // durable metadata proves the native session advanced from this exact bundle. + const browserBundle = await this.bridge.exportAuthBundle(); const metadata = this.bridge.readMetadata(); - if (sameTokens(browserTokens, nativeTokens)) { - this.acceptSnapshot(snapshot, false); + if (browserBundle === snapshot.authBundle) { + await this.acceptSnapshot(snapshot, false); return; } const browserMatchesLastAcknowledgedNative = metadata?.userId === userId && metadata.nativeInstanceId === snapshot.nativeInstanceId && - metadata.tokenFingerprint === tokenFingerprint(browserTokens); + metadata.bundleFingerprint === bundleFingerprint(browserBundle); if ( browserMatchesLastAcknowledgedNative && snapshot.revision > (metadata?.nativeRevision ?? 0) ) { - this.acceptSnapshot(snapshot, true); + await this.acceptSnapshot(snapshot, true); return; } @@ -257,8 +285,12 @@ export class MapleApiAuthService { } for (let attempt = 0; attempt < MAX_SYNC_ATTEMPTS; attempt += 1) { - const tokens = this.bridge.readTokens(); - if (!force && this.syncedAuth?.userId === userId && sameTokens(tokens, this.syncedAuth)) { + const authBundle = await this.bridge.exportAuthBundle(); + if ( + !force && + this.syncedAuth?.userId === userId && + authBundle === this.syncedAuth.authBundle + ) { return; } @@ -266,26 +298,25 @@ export class MapleApiAuthService { request: { userId, apiUrl: this.bridge.apiUrl(), - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken + authBundle } }); this.assertCurrentSnapshot(userId, snapshot); - // The browser SDK can rotate its pair while native candidate validation - // is in flight. Retry that newer pair before allowing the Agent command + // The browser SDK can rotate its bundle while native candidate validation + // is in flight. Retry that newer bundle before allowing the Agent command // waiting on this sync to continue. - if (!sameTokens(this.bridge.readTokens(), tokens)) { + if ((await this.bridge.exportAuthBundle()) !== authBundle) { force = true; continue; } - const acceptedTokens = this.snapshotTokens(snapshot); - if (!sameTokens(tokens, acceptedTokens)) { - // Candidate validation may itself refresh an expired JWT. - this.bridge.writeTokens(acceptedTokens); + if (authBundle !== snapshot.authBundle) { + // Candidate validation may itself rotate a descriptor or resumption + // credential. Import the native SDK's complete opaque replacement. + await this.bridge.importAuthBundle(snapshot.authBundle); } - this.acceptSnapshot(snapshot, false); + await this.acceptSnapshot(snapshot, false); return; } @@ -296,9 +327,9 @@ export class MapleApiAuthService { const userId = this.activeUserId; if (!userId || normalizeUserId(event.userId) !== userId) return; - const browserTokens = this.bridge.readTokens(); + const browserBundle = await this.bridge.exportAuthBundle(); const synced = this.syncedAuth; - if (!synced || synced.userId !== userId || !sameTokens(browserTokens, synced)) { + if (!synced || synced.userId !== userId || browserBundle !== synced.authBundle) { // The browser refreshed independently. Its current session remains // canonical, so install that pair instead of consuming a late native // refresh notification. @@ -314,12 +345,12 @@ export class MapleApiAuthService { // Re-read both sources after the await. Otherwise a browser rotation that // happened during get_auth could be overwritten by this stale snapshot. - const latestBrowserTokens = this.bridge.readTokens(); + const latestBrowserBundle = await this.bridge.exportAuthBundle(); const latestSynced = this.syncedAuth; if ( !latestSynced || latestSynced.userId !== userId || - !sameTokens(latestBrowserTokens, latestSynced) + latestBrowserBundle !== latestSynced.authBundle ) { await this.syncNow(userId, true); return; @@ -331,7 +362,7 @@ export class MapleApiAuthService { return; } if (snapshot.revision < latestSynced.revision) return; - this.acceptSnapshot(snapshot, true); + await this.acceptSnapshot(snapshot, true); } private assertCurrentSnapshot(userId: string, snapshot: MapleApiAuthSnapshot): void { @@ -339,6 +370,7 @@ export class MapleApiAuthService { this.activeUserId !== userId || normalizeUserId(snapshot.userId) !== userId || !snapshot.nativeInstanceId || + !snapshot.authBundle || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 1 ) { @@ -346,19 +378,14 @@ export class MapleApiAuthService { } } - private snapshotTokens(snapshot: MapleApiAuthSnapshot): BrowserTokenPair { - return { - accessToken: snapshot.accessToken, - refreshToken: snapshot.refreshToken || null - }; - } - - private acceptSnapshot(snapshot: MapleApiAuthSnapshot, writeTokens: boolean): void { - const tokens = this.snapshotTokens(snapshot); - if (writeTokens) this.bridge.writeTokens(tokens); + private async acceptSnapshot( + snapshot: MapleApiAuthSnapshot, + importBundle: boolean + ): Promise { + if (importBundle) await this.bridge.importAuthBundle(snapshot.authBundle); this.syncedAuth = { userId: normalizeUserId(snapshot.userId), - ...tokens, + authBundle: snapshot.authBundle, nativeInstanceId: snapshot.nativeInstanceId, revision: snapshot.revision }; @@ -366,7 +393,7 @@ export class MapleApiAuthService { userId: normalizeUserId(snapshot.userId), nativeInstanceId: snapshot.nativeInstanceId, nativeRevision: snapshot.revision, - tokenFingerprint: tokenFingerprint(tokens) + bundleFingerprint: bundleFingerprint(snapshot.authBundle) }); } } diff --git a/frontend/src/services/nativeOAuthAttempt.test.ts b/frontend/src/services/nativeOAuthAttempt.test.ts index ea9b23e8e..424ceca1f 100644 --- a/frontend/src/services/nativeOAuthAttempt.test.ts +++ b/frontend/src/services/nativeOAuthAttempt.test.ts @@ -1,112 +1,145 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { - authorizeNativeOAuthCallback, - beginNativeOAuthAttempt, - cancelNativeOAuthAttempt, - PENDING_NATIVE_OAUTH_ATTEMPT_TTL_MS -} from "./nativeOAuthAttempt"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; class MemoryStorage implements Storage { private readonly values = new Map(); - - get length(): number { + get length() { return this.values.size; } - - clear(): void { + clear() { this.values.clear(); } - - getItem(key: string): string | null { + getItem(key: string) { return this.values.get(key) ?? null; } - - key(index: number): string | null { + key(index: number) { return [...this.values.keys()][index] ?? null; } - - removeItem(key: string): void { + removeItem(key: string) { this.values.delete(key); } - - setItem(key: string, value: string): void { + setItem(key: string, value: string) { this.values.set(key, value); } } -const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); - -function restoreLocalStorage(): void { - if (originalLocalStorage) { - Object.defineProperty(globalThis, "localStorage", originalLocalStorage); - } else { - Reflect.deleteProperty(globalThis, "localStorage"); +const ATTEMPT_ONE = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; +const ATTEMPT_TWO = "11111111-2222-4333-8444-555555555555"; +const SESSION_ONE = "abcdef12-2222-3333-4444-555555555555"; +const SESSION_TWO = "98765432-2222-3333-4444-555555555555"; +const defaultInvoke = async (command: string) => { + if (command === "native_oauth_begin") { + return { nativeOAuthAttempt: ATTEMPT_ONE, sessionId: SESSION_ONE }; } -} + if (command === "native_oauth_redeem") { + return { userId: "user-one", authBundle: "opaque-bundle" }; + } + return undefined; +}; +const invoke = mock(defaultInvoke); -describe("native OAuth attempt authorization", () => { - let storage: MemoryStorage; +const { + authorizeNativeOAuthCallback, + beginNativeOAuthAttempt, + cancelNativeOAuthAttempt, + consumeNativeOAuthAttempt, + isNativeOAuthAttemptId, + isNativeOAuthSessionId, + PENDING_NATIVE_OAUTH_ATTEMPT_TTL_MS, + redeemNativeOAuthGrant +} = await import("./nativeOAuthAttempt"); +const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + +describe("native OAuth attempt authorization", () => { beforeEach(() => { - storage = new MemoryStorage(); + invoke.mockClear(); + invoke.mockImplementation(defaultInvoke); Object.defineProperty(globalThis, "localStorage", { configurable: true, - value: storage, + value: new MemoryStorage(), writable: true }); }); afterEach(() => { - restoreLocalStorage(); + if (originalLocalStorage) { + Object.defineProperty(globalThis, "localStorage", originalLocalStorage); + } else { + Reflect.deleteProperty(globalThis, "localStorage"); + } }); - test("a retry replaces the marker and an older failed attempt cannot cancel it", () => { - const firstAttemptId = beginNativeOAuthAttempt(1_000); - const secondAttemptId = beginNativeOAuthAttempt(2_000); + test("begins natively and mirrors the returned attempt and session", async () => { + const result = await beginNativeOAuthAttempt("https://api.example.test", 1_000, invoke); - expect(secondAttemptId).not.toBe(firstAttemptId); - cancelNativeOAuthAttempt(firstAttemptId); - expect(authorizeNativeOAuthCallback(false, 2_001)).toBe("accepted"); - expect(authorizeNativeOAuthCallback(false, 2_001)).toBe("missing_or_expired_attempt"); + expect(result).toEqual({ nativeOAuthAttempt: ATTEMPT_ONE, sessionId: SESSION_ONE }); + expect(invoke).toHaveBeenCalledWith("native_oauth_begin", { + request: { apiUrl: "https://api.example.test" } + }); + expect(authorizeNativeOAuthCallback(false, 1_001)).toBe("accepted"); + expect(authorizeNativeOAuthCallback(false, 1_002)).toBe("accepted"); + expect(consumeNativeOAuthAttempt(ATTEMPT_ONE)).toBe(true); + expect(authorizeNativeOAuthCallback(false, 1_003)).toBe("missing_or_expired_attempt"); }); - test("canceling the current browser-open attempt removes its marker", () => { - const attemptId = beginNativeOAuthAttempt(1_000); + test("rejects the nil UUID for both native attempt and session identifiers", async () => { + const nilUuid = "00000000-0000-0000-0000-000000000000"; + expect(isNativeOAuthAttemptId(nilUuid)).toBe(false); + expect(isNativeOAuthSessionId(nilUuid)).toBe(false); + + invoke.mockImplementation(async () => ({ + nativeOAuthAttempt: nilUuid, + sessionId: SESSION_ONE + })); + await expect( + beginNativeOAuthAttempt("https://api.example.test", 1_000, invoke) + ).rejects.toThrow("Native OAuth initiation returned invalid state"); + + invoke.mockImplementation(async () => ({ + nativeOAuthAttempt: ATTEMPT_ONE, + sessionId: nilUuid + })); + await expect( + beginNativeOAuthAttempt("https://api.example.test", 1_000, invoke) + ).rejects.toThrow("Native OAuth initiation returned invalid state"); + }); - cancelNativeOAuthAttempt(attemptId); + test("cancel invokes native for the exact attempt and cannot clear a replacement", async () => { + await beginNativeOAuthAttempt("https://api.example.test", 1_000, invoke); + invoke.mockImplementation(async (command: string) => { + if (command === "native_oauth_begin") { + return { nativeOAuthAttempt: ATTEMPT_TWO, sessionId: SESSION_TWO }; + } + return undefined; + }); + await beginNativeOAuthAttempt("https://api.example.test", 2_000, invoke); - expect(authorizeNativeOAuthCallback(false, 1_001)).toBe("missing_or_expired_attempt"); + await cancelNativeOAuthAttempt(ATTEMPT_ONE, invoke); + expect(invoke).toHaveBeenLastCalledWith("native_oauth_cancel", { + request: { nativeOAuthAttempt: ATTEMPT_ONE } + }); + expect(authorizeNativeOAuthCallback(false, 2_001)).toBe("accepted"); }); - test("rejects and clears an expired or future-dated marker", () => { - beginNativeOAuthAttempt(1_000); + test("authorization preserves the pending attempt until consumption and expiry fails closed", async () => { + await beginNativeOAuthAttempt("https://api.example.test", 1_000, invoke); + + expect(authorizeNativeOAuthCallback(false, 1_001)).toBe("accepted"); + expect(authorizeNativeOAuthCallback(false, 1_002)).toBe("accepted"); expect( authorizeNativeOAuthCallback(false, 1_000 + PENDING_NATIVE_OAUTH_ATTEMPT_TTL_MS + 1) ).toBe("missing_or_expired_attempt"); - - beginNativeOAuthAttempt(2_000); - expect(authorizeNativeOAuthCallback(false, 1_999)).toBe("missing_or_expired_attempt"); - expect(authorizeNativeOAuthCallback(false, 2_001)).toBe("missing_or_expired_attempt"); - }); - - test("rejects a callback for an authenticated user and clears the marker", () => { - beginNativeOAuthAttempt(1_000); - - expect(authorizeNativeOAuthCallback(true, 1_001)).toBe("already_authenticated"); - expect(authorizeNativeOAuthCallback(false, 1_001)).toBe("missing_or_expired_attempt"); - }); - - test("rejects an unsolicited callback without a marker", () => { - expect(authorizeNativeOAuthCallback(false, 1_000)).toBe("missing_or_expired_attempt"); }); - test("rejects and removes malformed marker data", () => { - beginNativeOAuthAttempt(1_000); - const markerKey = storage.key(0); - if (!markerKey) throw new Error("Expected the pending marker to be stored"); - storage.setItem(markerKey, "not-json"); + test("redeems through local IPC with the exact grant request", async () => { + const result = await redeemNativeOAuthGrant("header.payload.signature", SESSION_ONE, invoke); - expect(authorizeNativeOAuthCallback(false, 1_001)).toBe("missing_or_expired_attempt"); - expect(storage.getItem(markerKey)).toBeNull(); + expect(result).toEqual({ userId: "user-one", authBundle: "opaque-bundle" }); + expect(invoke).toHaveBeenCalledWith("native_oauth_redeem", { + request: { + handoffGrant: "header.payload.signature", + nativeSessionId: SESSION_ONE + } + }); }); }); diff --git a/frontend/src/services/nativeOAuthAttempt.ts b/frontend/src/services/nativeOAuthAttempt.ts index 838872041..7a1fe48d2 100644 --- a/frontend/src/services/nativeOAuthAttempt.ts +++ b/frontend/src/services/nativeOAuthAttempt.ts @@ -1,11 +1,23 @@ -import { v4 as uuidv4 } from "uuid"; +import { invoke } from "@tauri-apps/api/core"; -const PENDING_NATIVE_OAUTH_ATTEMPT_KEY = "maple_pending_native_oauth_attempt_v1"; +type InvokeCommand = (command: string, args?: Parameters[1]) => Promise; + +const PENDING_NATIVE_OAUTH_ATTEMPT_KEY = "maple_pending_native_oauth_attempt_v2"; export const PENDING_NATIVE_OAUTH_ATTEMPT_TTL_MS = 15 * 60 * 1000; -// This is a local freshness marker, not provider OAuth state and not a secret. +export interface NativeOAuthBeginResponse { + nativeOAuthAttempt: string; + sessionId: string; +} + +export interface NativeOAuthRedeemResponse { + userId: string; + authBundle: string; +} + interface PendingNativeOAuthAttempt { attemptId: string; + sessionId: string; startedAt: number; } @@ -14,6 +26,21 @@ export type NativeOAuthCallbackAuthorization = | "already_authenticated" | "missing_or_expired_attempt"; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const NIL_UUID = "00000000-0000-0000-0000-000000000000"; + +function isNonNilUuid(value: unknown): value is string { + return typeof value === "string" && value !== NIL_UUID && UUID_PATTERN.test(value); +} + +export function isNativeOAuthAttemptId(value: unknown): value is string { + return isNonNilUuid(value); +} + +export function isNativeOAuthSessionId(value: unknown): value is string { + return isNonNilUuid(value); +} + function readPendingNativeOAuthAttempt(): PendingNativeOAuthAttempt | null { let encoded: string | null; try { @@ -21,14 +48,13 @@ function readPendingNativeOAuthAttempt(): PendingNativeOAuthAttempt | null { } catch { return null; } - if (!encoded) return null; try { const attempt = JSON.parse(encoded) as Partial; if ( - typeof attempt.attemptId !== "string" || - !attempt.attemptId || + !isNativeOAuthAttemptId(attempt.attemptId) || + !isNativeOAuthSessionId(attempt.sessionId) || typeof attempt.startedAt !== "number" || !Number.isSafeInteger(attempt.startedAt) || attempt.startedAt < 0 @@ -48,10 +74,7 @@ function readPendingNativeOAuthAttempt(): PendingNativeOAuthAttempt | null { function removePendingNativeOAuthAttempt(attemptId?: string): boolean { try { - if (attemptId) { - const currentAttempt = readPendingNativeOAuthAttempt(); - if (currentAttempt?.attemptId !== attemptId) return false; - } + if (attemptId && readPendingNativeOAuthAttempt()?.attemptId !== attemptId) return false; localStorage.removeItem(PENDING_NATIVE_OAUTH_ATTEMPT_KEY); return localStorage.getItem(PENDING_NATIVE_OAUTH_ATTEMPT_KEY) === null; } catch { @@ -59,19 +82,60 @@ function removePendingNativeOAuthAttempt(attemptId?: string): boolean { } } -export function beginNativeOAuthAttempt(now = Date.now()): string { +export async function beginNativeOAuthAttempt( + apiUrl: string, + now = Date.now(), + invokeCommand: InvokeCommand = invoke +): Promise { if (!Number.isSafeInteger(now) || now < 0) { throw new Error("Cannot start native OAuth with an invalid timestamp"); } + const response = (await invokeCommand("native_oauth_begin", { + request: { apiUrl } + })) as NativeOAuthBeginResponse; + if ( + !isNativeOAuthAttemptId(response.nativeOAuthAttempt) || + !isNativeOAuthSessionId(response.sessionId) + ) { + throw new Error("Native OAuth initiation returned invalid state"); + } + try { + localStorage.setItem( + PENDING_NATIVE_OAUTH_ATTEMPT_KEY, + JSON.stringify({ + attemptId: response.nativeOAuthAttempt, + sessionId: response.sessionId, + startedAt: now + } satisfies PendingNativeOAuthAttempt) + ); + } catch (error) { + await invokeCommand("native_oauth_cancel", { + request: { nativeOAuthAttempt: response.nativeOAuthAttempt } + }).catch(() => undefined); + throw error; + } + return response; +} - const attemptId = uuidv4(); - const attempt: PendingNativeOAuthAttempt = { attemptId, startedAt: now }; - localStorage.setItem(PENDING_NATIVE_OAUTH_ATTEMPT_KEY, JSON.stringify(attempt)); - return attemptId; +export async function cancelNativeOAuthAttempt( + attemptId: string, + invokeCommand: InvokeCommand = invoke +): Promise { + try { + await invokeCommand("native_oauth_cancel", { request: { nativeOAuthAttempt: attemptId } }); + } finally { + removePendingNativeOAuthAttempt(attemptId); + } } -export function cancelNativeOAuthAttempt(attemptId: string): void { - removePendingNativeOAuthAttempt(attemptId); +export async function redeemNativeOAuthGrant( + handoffGrant: string, + nativeSessionId: string, + invokeCommand: InvokeCommand = invoke +): Promise { + return invokeCommand("native_oauth_redeem", { + request: { handoffGrant, nativeSessionId } + }) as Promise; } export function authorizeNativeOAuthCallback( @@ -82,7 +146,6 @@ export function authorizeNativeOAuthCallback( removePendingNativeOAuthAttempt(); return "already_authenticated"; } - const attempt = readPendingNativeOAuthAttempt(); if (!attempt) return "missing_or_expired_attempt"; @@ -91,10 +154,13 @@ export function authorizeNativeOAuthCallback( removePendingNativeOAuthAttempt(attempt.attemptId); return "missing_or_expired_attempt"; } + return "accepted"; +} - if (!removePendingNativeOAuthAttempt(attempt.attemptId)) { - return "missing_or_expired_attempt"; - } +export function readPendingNativeOAuthAttemptId(): string | null { + return readPendingNativeOAuthAttempt()?.attemptId ?? null; +} - return "accepted"; +export function consumeNativeOAuthAttempt(attemptId: string): boolean { + return removePendingNativeOAuthAttempt(attemptId); } diff --git a/proxy/.env.example b/proxy/.env.example index 4dededced..6dd92a12f 100644 --- a/proxy/.env.example +++ b/proxy/.env.example @@ -19,6 +19,12 @@ MAPLE_PCR0_ENVIRONMENT=production # Your Maple API key - get this from https://trymaple.ai MAPLE_API_KEY=your-maple-api-key-here +# Optional stable transport-v2 provider-cache namespace root. Generate 32 +# random bytes once, encode them as canonical padded base64, and keep the value +# in the same secret manager as the API key. When omitted, cache continuity is +# limited to this proxy process. Never share one root between installations. +# MAPLE_CACHE_NAMESPACE_ROOT= + # Debugging MAPLE_DEBUG=false diff --git a/proxy/Cargo.lock b/proxy/Cargo.lock index d680594cf..78f304116 100644 --- a/proxy/Cargo.lock +++ b/proxy/Cargo.lock @@ -1445,7 +1445,7 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "maple-proxy" -version = "0.3.4" +version = "0.4.0" dependencies = [ "anyhow", "async-stream", @@ -1637,7 +1637,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "opensecret" -version = "3.6.2" +version = "4.0.0" dependencies = [ "aes-gcm", "anyhow", diff --git a/proxy/Cargo.toml b/proxy/Cargo.toml index c78dece6f..417a51c35 100644 --- a/proxy/Cargo.toml +++ b/proxy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "maple-proxy" -version = "0.3.4" +version = "0.4.0" edition = "2021" authors = ["OpenSecret"] description = "Lightweight OpenAI-compatible proxy server for Maple/OpenSecret TEE infrastructure" @@ -29,7 +29,7 @@ path = "src/main.rs" [dependencies] # OpenSecret SDK -opensecret = { version = "3.6.2", path = "../sdk/rust" } +opensecret = { version = "4.0.0", path = "../sdk/rust" } # Web server axum = { version = "0.8.4", features = ["http2", "macros"] } diff --git a/proxy/README.md b/proxy/README.md index a704a9f94..b7de443aa 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -4,6 +4,10 @@ A lightweight proxy for Maple/OpenSecret's OpenAI-compatible inference endpoints, with the security and privacy benefits of Trusted Execution Environment (TEE) processing. +Version 0.4.0 and later use OpenSecret transport v2 exclusively for the +proxy-to-enclave connection. The local OpenAI-compatible `/v1/*` paths remain +unchanged; they are application routes, not the encrypted transport version. + ## 🚀 Features - **OpenAI-Compatible Surface** - Models, chat completions, and embeddings endpoints @@ -65,6 +69,7 @@ export MAPLE_PORT=8080 # Server port (default: 8080) export MAPLE_BACKEND_URL=http://localhost:3000 # Maple backend URL (prod: https://enclave.trymaple.ai) export MAPLE_PCR0_ENVIRONMENT=production # PCR0 trust roots: production (default) or development export MAPLE_API_KEY=your-maple-api-key # Optional for trusted, non-browser clients only +# export MAPLE_CACHE_NAMESPACE_ROOT= # Optional stable random 32-byte root in padded base64 export MAPLE_DEBUG=true # Enable debug logging export MAPLE_ENABLE_CORS=false # Default; see browser warning below export MAPLE_REQUEST_TIMEOUT_SECS=300 # Backend request timeout @@ -272,6 +277,27 @@ Override the default key or provide one if not set: curl -H "Authorization: Bearer different-api-key" ... ``` +### Provider-cache continuity + +Transport v2 keeps provider cache namespaces private from infrastructure +between a client-owned proxy and the enclave. The proxy generates one random +namespace root for all of its API-key clients and the enclave separates the +resulting namespaces by verified account owner. That default remains stable +across cached-client and session renewal, but not across a proxy restart. + +For restart-stable cache hits, generate 32 random bytes once, store them as a +secret, and supply their canonical padded-base64 encoding through +`MAPLE_CACHE_NAMESPACE_ROOT` or `--cache-namespace-root`. Prefer managed +environment injection because command-line arguments can be visible in process +listings. Do not derive the root from an API key or user identifier, log it, or +send it as a downstream HTTP header. A shared deployment still uses one +installation root, which the enclave binds to each verified account before +deriving the provider value. + +Such a deployment trusts the proxy operator with both that root and the API +keys clients submit; run the proxy on the client device when operator privacy is +part of the threat model. + ## 🌐 CORS Support The standalone proxy does not inherit Maple's Tauri wrapper protections. If a @@ -406,6 +432,7 @@ environment: - MAPLE_STREAM_IDLE_TIMEOUT_SECS=300 # Streaming idle timeout - RUST_LOG=info # Logging level # - MAPLE_API_KEY=xxx # Only for private deployments! + # - MAPLE_CACHE_NAMESPACE_ROOT=xxx # Stable secret; never bake into an image ``` ## 🔧 Development diff --git a/proxy/docker-compose.yml b/proxy/docker-compose.yml index 9f772cfe3..4486c71e1 100644 --- a/proxy/docker-compose.yml +++ b/proxy/docker-compose.yml @@ -24,6 +24,10 @@ services: # Authentication - Uncomment ONLY for private/internal deployments! # For public deployments: Keep this commented out - clients will pass their own API keys # - MAPLE_API_KEY=${MAPLE_API_KEY} + + # Optional restart-stable provider-cache root. Keep this value in a + # secret manager and use one independent random root per installation. + # - MAPLE_CACHE_NAMESPACE_ROOT=${MAPLE_CACHE_NAMESPACE_ROOT} # Optional configurations - MAPLE_DEBUG=${MAPLE_DEBUG:-false} diff --git a/proxy/justfile b/proxy/justfile index 016f3324e..4de6c9b5d 100644 --- a/proxy/justfile +++ b/proxy/justfile @@ -163,6 +163,7 @@ env: @echo "MAPLE_BACKEND_URL: ${MAPLE_BACKEND_URL:-https://enclave.trymaple.ai}" @echo "MAPLE_PCR0_ENVIRONMENT: ${MAPLE_PCR0_ENVIRONMENT:-production}" @echo "MAPLE_API_KEY: $(if [ -n \"${MAPLE_API_KEY:-}\" ]; then printf '[set]'; else printf '[not set]'; fi)" + @echo "MAPLE_CACHE_NAMESPACE_ROOT: $(if [ -n \"${MAPLE_CACHE_NAMESPACE_ROOT:-}\" ]; then printf '[set]'; else printf '[not set]'; fi)" @echo "MAPLE_DEBUG: ${MAPLE_DEBUG:-false}" @echo "MAPLE_ENABLE_CORS: ${MAPLE_ENABLE_CORS:-false}" @echo "MAPLE_REQUEST_TIMEOUT_SECS: ${MAPLE_REQUEST_TIMEOUT_SECS:-300}" diff --git a/proxy/src/config.rs b/proxy/src/config.rs index ee8080a9c..550d7d811 100644 --- a/proxy/src/config.rs +++ b/proxy/src/config.rs @@ -1,7 +1,10 @@ -use clap::Parser; -use opensecret::Pcr0Environment; +use clap::{ + error::{ContextKind, ContextValue}, + Parser, +}; +use opensecret::{Pcr0Environment, TransportV2CacheNamespaceRoot}; use serde::Serialize; -use std::{net::SocketAddr, time::Duration}; +use std::{ffi::OsString, net::SocketAddr, time::Duration}; pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 300; pub const DEFAULT_STREAM_IDLE_TIMEOUT_SECS: u64 = 300; @@ -37,9 +40,22 @@ pub struct Config { pub pcr0_environment: Pcr0Environment, /// Default API key for Maple/OpenSecret (can be overridden by client Authorization header) - #[arg(long, env = "MAPLE_API_KEY")] + #[arg(long, env = "MAPLE_API_KEY", hide_env_values = true)] pub default_api_key: Option, + /// Optional stable transport-v2 provider-cache namespace root. + /// + /// This is a secret, canonical padded-base64 encoding of exactly 32 bytes. + /// When omitted, the proxy generates one root for the lifetime of the + /// process and shares it across all API-key clients. + #[arg( + long, + env = "MAPLE_CACHE_NAMESPACE_ROOT", + hide_env_values = true, + value_parser = parse_cache_namespace_root + )] + pub cache_namespace_root: Option, + /// Enable debug logging #[arg(short, long, env = "MAPLE_DEBUG")] pub debug: bool, @@ -68,6 +84,41 @@ pub struct Config { } impl Config { + /// Parse process arguments while ensuring secret cache-root values never + /// appear in clap's rendered validation errors. + pub fn parse() -> Self { + match Self::try_parse() { + Ok(config) => config, + Err(error) => error.exit(), + } + } + + /// Parse process arguments and return a redacted validation error. + pub fn try_parse() -> Result { + Self::try_parse_from(std::env::args_os()) + } + + /// Parse supplied arguments, exiting with a redacted validation error. + pub fn parse_from(arguments: I) -> Self + where + I: IntoIterator, + T: Into + Clone, + { + match Self::try_parse_from(arguments) { + Ok(config) => config, + Err(error) => error.exit(), + } + } + + /// Parse supplied arguments with cache-root validation errors redacted. + pub fn try_parse_from(arguments: I) -> Result + where + I: IntoIterator, + T: Into + Clone, + { + ::try_parse_from(arguments).map_err(redact_cache_namespace_root_error) + } + pub fn socket_addr(&self) -> anyhow::Result { let addr = format!("{}:{}", self.host, self.port); addr.parse() @@ -89,6 +140,7 @@ impl Config { backend_url, pcr0_environment: Pcr0Environment::Production, default_api_key: None, + cache_namespace_root: None, debug: false, enable_cors: false, request_timeout_secs: DEFAULT_REQUEST_TIMEOUT_SECS, @@ -116,6 +168,15 @@ impl Config { self } + /// Supply a caller-persisted transport-v2 provider-cache namespace root. + pub fn with_cache_namespace_root( + mut self, + cache_namespace_root: TransportV2CacheNamespaceRoot, + ) -> Self { + self.cache_namespace_root = Some(cache_namespace_root); + self + } + /// Builder-style method to enable debug mode pub fn with_debug(mut self, debug: bool) -> Self { self.debug = debug; @@ -149,6 +210,26 @@ fn parse_pcr0_environment(value: &str) -> Result { } } +fn parse_cache_namespace_root(value: &str) -> Result { + TransportV2CacheNamespaceRoot::from_base64(value).map_err(|_| { + "cache namespace root must be canonical padded base64 for exactly 32 bytes".to_string() + }) +} + +fn redact_cache_namespace_root_error(mut error: clap::Error) -> clap::Error { + let is_cache_namespace_root = matches!( + error.get(ContextKind::InvalidArg), + Some(ContextValue::String(argument)) if argument.contains("--cache-namespace-root") + ); + if is_cache_namespace_root { + error.insert( + ContextKind::InvalidValue, + ContextValue::String("[REDACTED]".to_string()), + ); + } + error +} + #[derive(Debug, Serialize)] pub(crate) struct OpenAIError { error: OpenAIErrorDetails, @@ -187,28 +268,79 @@ impl OpenAIError { #[cfg(test)] mod tests { use super::*; - use clap::{error::ErrorKind, Parser}; + use clap::{error::ErrorKind, CommandFactory}; use std::sync::Mutex; - static PCR0_ENVIRONMENT_LOCK: Mutex<()> = Mutex::new(()); + static CONFIG_PARSING_LOCK: Mutex<()> = Mutex::new(()); - fn with_pcr0_environment_env(value: Option<&str>, run: impl FnOnce() -> T) -> T { - let _guard = PCR0_ENVIRONMENT_LOCK.lock().unwrap(); - let previous = std::env::var_os("MAPLE_PCR0_ENVIRONMENT"); + struct EnvironmentRestore { + name: &'static str, + previous: Option, + } + impl Drop for EnvironmentRestore { + fn drop(&mut self) { + match self.previous.take() { + Some(previous) => std::env::set_var(self.name, previous), + None => std::env::remove_var(self.name), + } + } + } + + fn with_environment_variable( + name: &'static str, + value: Option<&str>, + run: impl FnOnce() -> T, + ) -> T { + let _guard = CONFIG_PARSING_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _pcr0_restore = EnvironmentRestore { + name: "MAPLE_PCR0_ENVIRONMENT", + previous: std::env::var_os("MAPLE_PCR0_ENVIRONMENT"), + }; + let _cache_root_restore = EnvironmentRestore { + name: "MAPLE_CACHE_NAMESPACE_ROOT", + previous: std::env::var_os("MAPLE_CACHE_NAMESPACE_ROOT"), + }; + + std::env::remove_var("MAPLE_PCR0_ENVIRONMENT"); + std::env::remove_var("MAPLE_CACHE_NAMESPACE_ROOT"); match value { - Some(value) => std::env::set_var("MAPLE_PCR0_ENVIRONMENT", value), - None => std::env::remove_var("MAPLE_PCR0_ENVIRONMENT"), + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), } - let result = run(); + run() + } - match previous { - Some(previous) => std::env::set_var("MAPLE_PCR0_ENVIRONMENT", previous), - None => std::env::remove_var("MAPLE_PCR0_ENVIRONMENT"), - } + fn try_parse_config_from(arguments: I) -> Result + where + I: IntoIterator, + T: Into + Clone, + { + let _guard = CONFIG_PARSING_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _pcr0_restore = EnvironmentRestore { + name: "MAPLE_PCR0_ENVIRONMENT", + previous: std::env::var_os("MAPLE_PCR0_ENVIRONMENT"), + }; + let _cache_root_restore = EnvironmentRestore { + name: "MAPLE_CACHE_NAMESPACE_ROOT", + previous: std::env::var_os("MAPLE_CACHE_NAMESPACE_ROOT"), + }; + std::env::remove_var("MAPLE_PCR0_ENVIRONMENT"); + std::env::remove_var("MAPLE_CACHE_NAMESPACE_ROOT"); + Config::try_parse_from(arguments) + } - result + fn with_pcr0_environment_env(value: Option<&str>, run: impl FnOnce() -> T) -> T { + with_environment_variable("MAPLE_PCR0_ENVIRONMENT", value, run) + } + + fn with_cache_namespace_root_env(value: Option<&str>, run: impl FnOnce() -> T) -> T { + with_environment_variable("MAPLE_CACHE_NAMESPACE_ROOT", value, run) } #[test] @@ -221,6 +353,7 @@ mod tests { assert_eq!(config.request_timeout_secs, DEFAULT_REQUEST_TIMEOUT_SECS); assert_eq!(config.pcr0_environment, Pcr0Environment::Production); + assert!(config.cache_namespace_root.is_none()); assert_eq!( config.stream_idle_timeout_secs, DEFAULT_STREAM_IDLE_TIMEOUT_SECS @@ -246,7 +379,7 @@ mod tests { #[test] fn pcr0_environment_accepts_explicit_development_cli_value() { let config = - Config::try_parse_from(["maple-proxy", "--pcr0-environment", "development"]).unwrap(); + try_parse_config_from(["maple-proxy", "--pcr0-environment", "development"]).unwrap(); assert_eq!(config.pcr0_environment, Pcr0Environment::Development); } @@ -275,7 +408,7 @@ mod tests { #[test] fn pcr0_environment_rejects_unknown_values() { let error = - Config::try_parse_from(["maple-proxy", "--pcr0-environment", "staging"]).unwrap_err(); + try_parse_config_from(["maple-proxy", "--pcr0-environment", "staging"]).unwrap_err(); assert_eq!(error.kind(), ErrorKind::ValueValidation); } @@ -297,11 +430,105 @@ mod tests { #[test] fn timeout_cli_values_must_be_positive() { let request_timeout_error = - Config::try_parse_from(["maple-proxy", "--request-timeout-secs", "0"]).unwrap_err(); + try_parse_config_from(["maple-proxy", "--request-timeout-secs", "0"]).unwrap_err(); assert_eq!(request_timeout_error.kind(), ErrorKind::ValueValidation); let stream_idle_timeout_error = - Config::try_parse_from(["maple-proxy", "--stream-idle-timeout-secs", "0"]).unwrap_err(); + try_parse_config_from(["maple-proxy", "--stream-idle-timeout-secs", "0"]).unwrap_err(); assert_eq!(stream_idle_timeout_error.kind(), ErrorKind::ValueValidation); } + + #[test] + fn cache_namespace_root_accepts_canonical_cli_and_builder_values() { + let encoded = TransportV2CacheNamespaceRoot::from_bytes([0x42; 32]).to_base64(); + let cli = + try_parse_config_from(["maple-proxy", "--cache-namespace-root", encoded.as_str()]) + .unwrap(); + assert_eq!( + cli.cache_namespace_root.as_ref().unwrap().to_base64(), + encoded + ); + + let built = Config::new( + "127.0.0.1".to_string(), + 8080, + "https://enclave.trymaple.ai".to_string(), + ) + .with_cache_namespace_root(TransportV2CacheNamespaceRoot::from_bytes([0x42; 32])); + assert_eq!( + built.cache_namespace_root.as_ref().unwrap().to_base64(), + encoded + ); + } + + #[test] + fn cache_namespace_root_accepts_canonical_environment_value() { + let encoded = TransportV2CacheNamespaceRoot::from_bytes([0x24; 32]).to_base64(); + let config = with_cache_namespace_root_env(Some(&encoded), || { + Config::try_parse_from(["maple-proxy"]).unwrap() + }); + + assert_eq!( + config.cache_namespace_root.as_ref().unwrap().to_base64(), + encoded + ); + } + + #[test] + fn cache_namespace_root_rejects_noncanonical_or_wrong_length_values() { + for value in [ + "not-base64", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ] { + let error = try_parse_config_from(["maple-proxy", "--cache-namespace-root", value]) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::ValueValidation); + let rendered = error.to_string(); + assert!(rendered.contains("[REDACTED]")); + assert!(!rendered.contains(value)); + } + } + + #[test] + fn cache_namespace_root_environment_errors_redact_the_value() { + let value = "ENV_CACHE_NAMESPACE_ROOT_CANARY"; + let error = with_cache_namespace_root_env(Some(value), || { + Config::try_parse_from(["maple-proxy"]).unwrap_err() + }); + + assert_eq!(error.kind(), ErrorKind::ValueValidation); + let rendered = error.to_string(); + assert!(rendered.contains("[REDACTED]")); + assert!(!rendered.contains(value)); + } + + #[test] + fn config_debug_redacts_cache_namespace_root() { + let root = TransportV2CacheNamespaceRoot::from_bytes([0x5a; 32]); + let encoded = root.to_base64(); + let config = Config::new( + "127.0.0.1".to_string(), + 8080, + "https://enclave.trymaple.ai".to_string(), + ) + .with_cache_namespace_root(root); + + let rendered = format!("{config:?}"); + assert!(rendered.contains("[REDACTED]")); + assert!(!rendered.contains(&encoded)); + } + + #[test] + fn help_hides_secret_environment_values() { + let command = Config::command(); + + for id in ["default_api_key", "cache_namespace_root"] { + let argument = command + .get_arguments() + .find(|argument| argument.get_id().as_str() == id) + .unwrap(); + assert!(argument.is_hide_env_values_set()); + } + } } diff --git a/proxy/src/lib.rs b/proxy/src/lib.rs index 2650eaeba..29781c50f 100644 --- a/proxy/src/lib.rs +++ b/proxy/src/lib.rs @@ -2,7 +2,7 @@ mod config; mod proxy; pub use config::Config; -pub use opensecret::Pcr0Environment; +pub use opensecret::{Pcr0Environment, TransportV2CacheNamespaceRoot}; use proxy::{health_check, proxy_openai_request, ProxyState}; use axum::{ diff --git a/proxy/src/proxy.rs b/proxy/src/proxy.rs index c13b76f52..df55f0399 100644 --- a/proxy/src/proxy.rs +++ b/proxy/src/proxy.rs @@ -8,7 +8,10 @@ use axum::{ }; use dashmap::DashMap; use futures::{future::BoxFuture, Stream, StreamExt}; -use opensecret::{client::OpenSecretResponseBody, OpenSecretClient, Result as OpenSecretResult}; +use opensecret::{ + client::OpenSecretResponseBody, OpenSecretClient, Result as OpenSecretResult, + TransportV2CacheNamespaceRoot, +}; use std::{ collections::HashSet, io, @@ -62,6 +65,7 @@ impl CachedClientEntry { pub(crate) struct ProxyState { config: Config, clients: DashMap>, + cache_namespace_root: OnceCell, transport_override: Option>, } @@ -70,6 +74,7 @@ impl ProxyState { Self { config, clients: DashMap::new(), + cache_namespace_root: OnceCell::new(), transport_override: None, } } @@ -79,10 +84,25 @@ impl ProxyState { Self { config, clients: DashMap::new(), + cache_namespace_root: OnceCell::new(), transport_override: Some(transport), } } + async fn cache_namespace_root(&self) -> Result { + self.cache_namespace_root + .get_or_try_init(|| async { + match &self.config.cache_namespace_root { + Some(root) => Ok(root.clone()), + None => TransportV2CacheNamespaceRoot::generate().map_err(|error| { + transport_error_response("Provider-cache namespace setup", &error) + }), + } + }) + .await + .cloned() + } + fn client_entry_for_api_key(&self, api_key: &str) -> Arc { let now = Instant::now(); @@ -110,19 +130,18 @@ impl ProxyState { let pcr0_environment = self.config.pcr0_environment; let request_timeout = self.config.request_timeout(); let init_api_key = cache_key.clone(); + let cache_namespace_root = self.cache_namespace_root().await?; let client = client_entry .cell .get_or_try_init(|| async move { - debug!( - "Creating OpenSecret client for API key: {}...", - &init_api_key[..8.min(init_api_key.len())] - ); + debug!("Creating OpenSecret API-key client"); create_client_with_auth( &backend_url, &init_api_key, pcr0_environment, request_timeout, + cache_namespace_root, ) .await .map(Arc::new) @@ -211,13 +230,15 @@ async fn create_client_with_auth( api_key: &str, pcr0_environment: opensecret::Pcr0Environment, request_timeout: Duration, + cache_namespace_root: TransportV2CacheNamespaceRoot, ) -> Result { let client = OpenSecretClient::new_with_api_key_and_pcr0_environment( backend_url, api_key.to_string(), pcr0_environment, ) - .map_err(|e| transport_error_response("OpenSecret client creation", &e))?; + .map_err(|e| transport_error_response("OpenSecret client creation", &e))? + .with_cache_namespace_root(cache_namespace_root); // Perform attestation handshake tokio::time::timeout(request_timeout, client.perform_attestation_handshake()) @@ -256,12 +277,7 @@ pub(crate) async fn proxy_openai_request( let api_key = extract_api_key(&headers, &state.config.default_api_key) .map_err(|e| (StatusCode::UNAUTHORIZED, Json(e)))?; - debug!( - "Proxying {} {} for API key: {}...", - method, - uri, - &api_key[..8.min(api_key.len())] - ); + debug!("Proxying {} {}", method, uri); let transport = state.transport_for_api_key(&api_key).await?; let request = build_upstream_request(method, uri, &headers, body); @@ -437,6 +453,7 @@ mod tests { backend_url: "http://localhost:3000".to_string(), pcr0_environment: opensecret::Pcr0Environment::Production, default_api_key: None, + cache_namespace_root: None, debug: false, enable_cors: false, request_timeout_secs: 300, @@ -579,6 +596,26 @@ mod tests { assert!(!state.clients.contains_key("key-a")); } + #[tokio::test] + async fn reuses_one_cache_namespace_root_across_api_key_clients() { + let state = ProxyState::new(test_config()); + + let first = state.cache_namespace_root().await.unwrap(); + let second = state.cache_namespace_root().await.unwrap(); + + assert_eq!(first, second); + } + + #[tokio::test] + async fn uses_the_configured_cache_namespace_root() { + let expected = TransportV2CacheNamespaceRoot::from_bytes([0x42; 32]); + let mut config = test_config(); + config.cache_namespace_root = Some(expected.clone()); + let state = ProxyState::new(config); + + assert_eq!(state.cache_namespace_root().await.unwrap(), expected); + } + #[tokio::test] async fn all_explicit_inference_routes_forward_method_uri_headers_and_exact_body() { let responses = (0..3) @@ -729,14 +766,20 @@ mod tests { } #[tokio::test] - async fn upstream_server_error_status_and_body_are_preserved() { - let error_body = Bytes::from_static(b"provider unavailable"); + async fn authenticated_capacity_exhaustion_is_preserved_without_retry() { + let error_body = Bytes::from_static( + br#"{"error":{"message":"session exhausted","type":"session_exhausted"}}"#, + ); let transport = Arc::new(MockTransport::new(vec![Ok(raw_response( StatusCode::SERVICE_UNAVAILABLE, - &[("content-type", "text/plain"), ("retry-after", "2")], + &[ + ("content-type", "application/json"), + ("retry-after", "2"), + ("x-request-id", "req-capacity"), + ], vec![error_body.clone()], ))])); - let response = mock_app(transport) + let response = mock_app(Arc::clone(&transport)) .oneshot( AxumRequest::builder() .method(Method::GET) @@ -749,10 +792,16 @@ mod tests { assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); assert_eq!(response.headers()[header::RETRY_AFTER], "2"); + assert_eq!(response.headers()["x-request-id"], "req-capacity"); assert_eq!( to_bytes(response.into_body(), 1024).await.unwrap(), error_body ); + assert_eq!( + transport.take_requests().len(), + 1, + "the proxy must forward an authenticated capacity response without retrying" + ); } #[tokio::test] diff --git a/proxy/tests/config_secret_redaction.rs b/proxy/tests/config_secret_redaction.rs new file mode 100644 index 000000000..295fd5592 --- /dev/null +++ b/proxy/tests/config_secret_redaction.rs @@ -0,0 +1,34 @@ +use std::process::{Command, Output}; + +fn assert_secret_value_is_redacted(output: Output, canary: &str) { + assert_eq!(output.status.code(), Some(2)); + let rendered = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(rendered.contains("[REDACTED]")); + assert!(!rendered.contains(canary)); +} + +#[test] +fn invalid_cli_cache_root_is_redacted_by_the_proxy_binary() { + let canary = "CLI_CACHE_NAMESPACE_ROOT_CANARY"; + let output = Command::new(env!("CARGO_BIN_EXE_maple-proxy")) + .args(["--cache-namespace-root", canary]) + .output() + .unwrap(); + + assert_secret_value_is_redacted(output, canary); +} + +#[test] +fn invalid_environment_cache_root_is_redacted_by_the_proxy_binary() { + let canary = "ENV_CACHE_NAMESPACE_ROOT_CANARY"; + let output = Command::new(env!("CARGO_BIN_EXE_maple-proxy")) + .env("MAPLE_CACHE_NAMESPACE_ROOT", canary) + .output() + .unwrap(); + + assert_secret_value_is_redacted(output, canary); +} diff --git a/sdk/README.md b/sdk/README.md index 664af216f..2d371fc94 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -73,6 +73,17 @@ conversations, inference, and account operations. Internal developer tooling uses `OpenSecretDeveloper` and `useOpenSecretDeveloper`; preserve that surface when changing the public exports. +Hosted OAuth callbacks that return to a native application can call +`mintNativeHandoffGrant(nativeSessionId, nativeAttemptId)` through the +OpenSecret context. It creates a short-lived grant bound to the native app's +already-attested transport-v2 session. The grant is redeemed by the native +client; the TypeScript SDK deliberately does not expose native redemption. + +The auth-bundle export/import helpers are for trusted local IPC and protected +application storage only. Their output contains reusable credentials and the +client-held cache namespace root; it is encoded, not encrypted, and must never +be placed in a URL, log, clipboard, or other untrusted handoff. + ### Development Use the pinned Nix shell and Bun version: @@ -116,7 +127,7 @@ Add the crate to a Rust application: ```toml [dependencies] -opensecret = "3" +opensecret = "4" ``` The primary entry point is `OpenSecretClient`. See `rust/README.md` for native diff --git a/sdk/opensecret-integration-revision b/sdk/opensecret-integration-revision index e7f0a0687..f01e344ac 100644 --- a/sdk/opensecret-integration-revision +++ b/sdk/opensecret-integration-revision @@ -1 +1 @@ -3f3c9aff9d4dbcfdcaf945c06ecf0e7ed6dae605 +6df5f92f8ba6909b6c8051e9b6b4c80bce4d6b6b diff --git a/sdk/package.json b/sdk/package.json index 0d859e4e8..3286ee831 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@opensecret/react", - "version": "3.5.2", + "version": "4.0.0", "packageManager": "bun@1.3.5", "license": "MIT", "type": "module", diff --git a/sdk/rust/Cargo.lock b/sdk/rust/Cargo.lock index 9d19e31f5..288932a6e 100644 --- a/sdk/rust/Cargo.lock +++ b/sdk/rust/Cargo.lock @@ -1256,7 +1256,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "opensecret" -version = "3.6.2" +version = "4.0.0" dependencies = [ "aes-gcm", "anyhow", diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml index 00c93f3e8..b14d2b2ba 100644 --- a/sdk/rust/Cargo.toml +++ b/sdk/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opensecret" -version = "3.6.2" +version = "4.0.0" edition = "2021" authors = ["OpenSecret"] description = "Rust SDK for OpenSecret - secure AI API interactions with nitro attestation" diff --git a/sdk/rust/README.md b/sdk/rust/README.md index 63fdd3b75..c315cfccc 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -21,7 +21,7 @@ Add to your `Cargo.toml`: ```toml [dependencies] -opensecret = "3.6.2" +opensecret = "4.0.0" bytes = "1" futures = "0.3" http = "1" @@ -96,9 +96,10 @@ use mock attestation; Android also supports the exact emulator alias `send_inference_request` is the lossless inference API. The caller owns the HTTP method, route, query, headers, body bytes, and response parsing; the SDK -owns attestation, authentication, encryption, retrying an expired session, and -response decryption. It does not parse or rewrite inference parameters such as -`stream`: +owns attestation, authentication, encryption, pre-request session resumption, +and response decryption. For Chat Completions it reads—but never rewrites—the +top-level boolean `stream` selector so it can authenticate the correct unary or +streaming response shape: ```rust use bytes::Bytes; @@ -132,6 +133,8 @@ The SDK manages transport credentials and framing. Caller-provided `Host`, `Authorization`, `x-session-id`, `Content-Length`, `Content-Type`, `Content-Encoding`, `Accept-Encoding`, `Content-MD5`, `Digest`, hop-by-hop, and `Connection`-listed headers are not forwarded; other headers are preserved. +Transport v2 never sends an outer `Authorization` header and never +automatically resends a request after bytes may have reached the enclave. ## Authentication @@ -177,7 +180,9 @@ let response = client.login_with_id( ### Token Management -Tokens are automatically stored after login/registration. You can: +Transport-v2 access and resumption descriptors are automatically stored after +login/registration. Legacy v1 JWT pairs are intentionally rejected and require +one fresh login. You can: ```rust // Get one coherent access/refresh pair snapshot @@ -196,14 +201,19 @@ client.logout().await?; ## Session Management -Every API call requires an encrypted session: +Application calls use authority-scoped encrypted sessions: -1. **Attestation Handshake**: Establishes trust and exchanges encryption keys -2. **Encrypted Communication**: All subsequent calls use the session key -3. **Token Authentication**: Protected endpoints require valid access tokens +1. **Attestation Handshake**: A fresh client nonce and configured PCR0 policy + are verified before key exchange. +2. **Encrypted Communication**: Method, path, query, headers, body, credential + transition, request ID, and response mode live inside one authenticated + request envelope. +3. **Authority Binding**: Anonymous sessions may become user- or API-key-bound; + protected requests and their responses stay on that same session. +4. **Replay Defense**: Every request carries a random per-session request ID. ```rust -// Required before any API calls +// Optional eager setup; API methods also establish sessions lazily. client.perform_attestation_handshake().await?; // Check session status @@ -212,6 +222,24 @@ if let Some(session_id) = client.get_session_id()? { } ``` +The SDK generates a random provider-cache namespace root for each client by +default. Applications that need cache continuity across restarts should persist +an independent random 32-byte root and provide it at construction time. Never +derive it from a user ID or API key: + +```rust +use opensecret::TransportV2CacheNamespaceRoot; + +let root = TransportV2CacheNamespaceRoot::generate()?; +let client = OpenSecretClient::new("https://api.opensecret.com")? + .with_cache_namespace_root(root); +``` + +Desktop/browser handoff can keep descriptors and the cache root together using +the opaque, origin-bound `export_transport_v2_auth_bundle` and +`import_transport_v2_auth_bundle` methods. Callers should store and transport +the returned string opaquely rather than parsing its internal representation. + ## Error Handling The SDK uses a custom `Error` type with detailed error variants: diff --git a/sdk/rust/src/client.rs b/sdk/rust/src/client.rs index 7901dd8ca..9de643476 100644 --- a/sdk/rust/src/client.rs +++ b/sdk/rust/src/client.rs @@ -1,25 +1,34 @@ use crate::{ - attestation::{AttestationDocument, AttestationVerifier}, - cbor::{self, Value as CborValue}, - crypto::{self}, error::{Error, Result}, pcr::{Pcr0Environment, Pcr0TrustPolicy}, - session::SessionManager, + session::{CredentialSnapshot, SessionManager, UserAuthEpoch}, + transport_v2::{ + decode_auth_bundle, encode_auth_bundle, validate_v2_user_token_pair, ApiKeyScope, + CacheNamespaceRoot, Credential, HeaderField, LogicalMethod, LogicalRequest, ResponseMode, + TransportV2Client, V2HttpResponse, V2Session, ValidatedUserTokenPair, + }, types::*, }; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use bytes::{Bytes, BytesMut}; use futures::{Stream, StreamExt}; use http::{header, HeaderMap as HttpHeaderMap, Request as HttpRequest, Response as HttpResponse}; +use p256::elliptic_curve::rand_core::{OsRng, RngCore}; use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; -use reqwest::{ - header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}, - Client, +use serde::{ + de::{self, DeserializeOwned, Deserializer as _, IgnoredAny, MapAccess, Visitor}, + Serialize, +}; +use sha2::{Digest, Sha256}; +use std::{ + fmt, + pin::Pin, + sync::{Arc, Mutex as StdMutex, MutexGuard as StdMutexGuard}, + time::{SystemTime, UNIX_EPOCH}, }; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use std::{net::IpAddr, pin::Pin}; use tokio::sync::Mutex; use uuid::Uuid; +use zeroize::Zeroizing; /// A decrypted response body returned by [`OpenSecretClient::send_inference_request`]. /// @@ -34,28 +43,145 @@ pub type InferenceRequest = HttpRequest; /// A decrypted HTTP response from an OpenSecret inference endpoint. pub type InferenceResponse = HttpResponse; -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct EncryptedBody { - encrypted: String, +/// Public metadata for one anonymous session retained for native OAuth. +/// +/// The session identifier is public correlation data, not an authentication +/// credential. A handoff grant can be redeemed only by the client that still +/// holds this exact attested session's encryption keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NativeOAuthSessionInfo { + pub session_id: Uuid, +} + +/// One short-lived backend-issued native OAuth handoff grant. +/// +/// Grants are compact signed JWTs. Their debug representation is always +/// redacted, and their storage is zeroized when dropped. +pub struct NativeOAuthHandoffGrant(Zeroizing); + +impl NativeOAuthHandoffGrant { + /// Validate and retain one canonical compact-JWT handoff grant. + pub fn new(grant: impl Into) -> Result { + const MAX_GRANT_BYTES: usize = 4_096; + + let grant = Zeroizing::new(grant.into()); + let mut segments = grant.split('.'); + let valid_segment = |segment: &str| { + !segment.is_empty() + && segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + }; + let valid = !grant.is_empty() + && grant.len() <= MAX_GRANT_BYTES + && segments.next().is_some_and(valid_segment) + && segments.next().is_some_and(valid_segment) + && segments.next().is_some_and(valid_segment) + && segments.next().is_none(); + if !valid { + return Err(Error::Authentication( + "Native OAuth handoff grant must be one canonical compact JWT of at most 4096 bytes" + .to_string(), + )); + } + + Ok(Self(grant)) + } +} + +impl fmt::Debug for NativeOAuthHandoffGrant { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("NativeOAuthHandoffGrant([REDACTED])") + } +} + +#[derive(Serialize)] +struct NativeOAuthHandoffRedeemRequest<'a> { + grant: &'a str, + native_attempt_id: Uuid, +} + +/// Caller-persistable provider-cache namespace root for transport v2. +/// +/// Generate this independently from account identifiers and credentials. Its +/// debug representation is always redacted. +#[derive(Clone, PartialEq, Eq, zeroize::Zeroize, zeroize::ZeroizeOnDrop)] +pub struct TransportV2CacheNamespaceRoot([u8; 32]); + +impl TransportV2CacheNamespaceRoot { + pub fn generate() -> Result { + Ok(Self(random_cache_namespace_root()?)) + } + + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + pub fn from_base64(encoded: &str) -> Result { + let decoded = Zeroizing::new(BASE64.decode(encoded)?); + if decoded.len() != 32 || BASE64.encode(decoded.as_slice()) != encoded { + return Err(Error::Configuration( + "Transport v2 cache namespace root must be canonical padded base64 for exactly 32 bytes" + .to_string(), + )); + } + let mut bytes = [0_u8; 32]; + bytes.copy_from_slice(decoded.as_slice()); + Ok(Self(bytes)) + } + + pub fn to_base64(&self) -> String { + BASE64.encode(self.0) + } + + fn into_bytes(mut self) -> [u8; 32] { + let bytes = self.0; + self.0 = [0_u8; 32]; + bytes + } } -const MAX_INFERENCE_SSE_LINE_BYTES: usize = 16 * 1024 * 1024; +impl std::fmt::Debug for TransportV2CacheNamespaceRoot { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("TransportV2CacheNamespaceRoot([REDACTED])") + } +} pub struct OpenSecretClient { - client: Client, - base_url: String, session_manager: SessionManager, + auth_commit_lock: StdMutex<()>, refresh_lock: Mutex<()>, - use_mock_attestation: bool, - pcr0_trust_policy: Pcr0TrustPolicy, + transport_v2: TransportV2Client, +} + +struct V2SendOptions { + response_mode: ResponseMode, + credential: Option, + cache_namespace_root: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] -struct ResolvedAuth { - token: Option, - using_api_key: bool, - generation: u64, +const V2_USER_AUTH_RENEWAL_SKEW_SECONDS: u64 = 30; + +impl V2SendOptions { + const fn bound(response_mode: ResponseMode) -> Self { + Self { + response_mode, + credential: None, + cache_namespace_root: None, + } + } + + fn transition( + response_mode: ResponseMode, + credential: Option, + cache_namespace_root: CacheNamespaceRoot, + ) -> Self { + Self { + response_mode, + credential, + cache_namespace_root: Some(cache_namespace_root), + } + } } fn append_query_param(query: &mut Vec, key: &str, value: impl ToString) { @@ -176,59 +302,6 @@ fn build_conversation_projects_endpoint(params: Option<&ConversationProjectListP endpoint } -#[derive(Clone, Copy, PartialEq, Eq)] -enum AuthHeaderMode { - None, - Jwt, - ApiKeyOrJwt, -} - -const ERROR_CONTRACT_HEADER: &str = "x-opensecret-error-contract"; -const ERROR_CODE_HEADER: &str = "x-opensecret-error-code"; -const ERROR_CONTRACT_VERSION: &[u8] = b"1"; -const SESSION_NOT_FOUND_ERROR_CODE: &[u8] = b"session_not_found"; -const ACCESS_TOKEN_EXPIRED_ERROR_CODE: &[u8] = b"access_token_expired"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RecoveryAction { - Reattest, - RefreshAccessToken, -} - -fn classify_response_recovery( - status: reqwest::StatusCode, - headers: &HeaderMap, -) -> Option { - let mut contract_values = headers.get_all(ERROR_CONTRACT_HEADER).iter(); - let Some(contract_version) = contract_values.next() else { - return match status { - reqwest::StatusCode::BAD_REQUEST => Some(RecoveryAction::Reattest), - reqwest::StatusCode::UNAUTHORIZED => Some(RecoveryAction::RefreshAccessToken), - _ => None, - }; - }; - - if contract_values.next().is_some() || contract_version.as_bytes() != ERROR_CONTRACT_VERSION { - return None; - } - - let mut code_values = headers.get_all(ERROR_CODE_HEADER).iter(); - let code = code_values.next()?; - if code_values.next().is_some() { - return None; - } - - match (status, code.as_bytes()) { - (reqwest::StatusCode::BAD_REQUEST, SESSION_NOT_FOUND_ERROR_CODE) => { - Some(RecoveryAction::Reattest) - } - (reqwest::StatusCode::UNAUTHORIZED, ACCESS_TOKEN_EXPIRED_ERROR_CODE) => { - Some(RecoveryAction::RefreshAccessToken) - } - _ => None, - } -} - fn is_allowed_inference_endpoint(method: &http::Method, path: &str) -> bool { matches!( (method.as_str(), path), @@ -241,6 +314,52 @@ fn is_allowed_inference_endpoint(method: &http::Method, path: &str) -> bool { ) } +fn inference_response_mode(path: &str, body: &[u8]) -> Result { + if path != "/v1/chat/completions" { + return Ok(ResponseMode::Unary); + } + + struct StreamSelectionVisitor; + impl<'de> Visitor<'de> for StreamSelectionVisitor { + type Value = bool; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a chat completion JSON object") + } + + fn visit_map(self, mut map: A) -> std::result::Result + where + A: MapAccess<'de>, + { + let mut stream = None; + while let Some(key) = map.next_key::()? { + if key == "stream" { + if stream.is_some() { + return Err(de::Error::duplicate_field("stream")); + } + stream = Some(map.next_value::()?); + } else { + map.next_value::()?; + } + } + Ok(stream.unwrap_or(false)) + } + } + + let mut deserializer = serde_json::Deserializer::from_slice(body); + let Ok(stream) = deserializer.deserialize_map(StreamSelectionVisitor) else { + return Ok(ResponseMode::Unary); + }; + if deserializer.end().is_err() { + return Ok(ResponseMode::Unary); + } + Ok(if stream { + ResponseMode::Stream + } else { + ResponseMode::Unary + }) +} + fn is_hop_by_hop_header(name: &http::HeaderName) -> bool { matches!( name.as_str(), @@ -273,6 +392,8 @@ fn sanitize_inference_request_headers(headers: &HttpHeaderMap) -> HttpHeaderMap && !connection_headers.contains(name) && *name != header::HOST && *name != header::AUTHORIZATION + && *name != header::COOKIE + && *name != header::SET_COOKIE && name.as_str() != "x-session-id" && *name != header::CONTENT_LENGTH && *name != header::CONTENT_TYPE @@ -280,6 +401,15 @@ fn sanitize_inference_request_headers(headers: &HttpHeaderMap) -> HttpHeaderMap && *name != header::ACCEPT_ENCODING && name.as_str() != "content-md5" && name.as_str() != "digest" + && name.as_str() != "proxy-connection" + && name.as_str() != "x-api-key" + && name.as_str() != "api-key" + && name.as_str() != "x-openai-api-key" + && name.as_str() != "x-tinfoil-api-key" + && name.as_str() != "x-goog-api-key" + && name.as_str() != "x-anthropic-api-key" + && name.as_str() != "openai-organization" + && name.as_str() != "openai-project" }) .map(|(name, value)| (name.clone(), value.clone())) .collect() @@ -311,111 +441,6 @@ fn sanitize_inference_response_headers(headers: &HttpHeaderMap) -> HttpHeaderMap .collect() } -fn is_event_stream(headers: &HttpHeaderMap) -> bool { - headers - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.split(';').next()) - .is_some_and(|media_type| media_type.trim().eq_ignore_ascii_case("text/event-stream")) -} - -fn transform_sse_line(line: Bytes, session_key: &[u8; 32]) -> Result { - let (content, line_ending) = if line.ends_with(b"\r\n") { - (&line[..line.len() - 2], &b"\r\n"[..]) - } else if line.ends_with(b"\n") { - (&line[..line.len() - 1], &b"\n"[..]) - } else { - (&line[..], &b""[..]) - }; - - let Some(mut payload) = content.strip_prefix(b"data:") else { - return Ok(line); - }; - let prefix_len = if payload.starts_with(b" ") { - payload = &payload[1..]; - 6 - } else { - 5 - }; - - if payload == b"[DONE]" { - return Ok(line); - } - - if payload.is_empty() { - return Ok(line); - } - - // OpenSecret encrypts every normal data event. Plaintext, malformed, or - // unauthenticated data is corrupt transport data and must never be passed - // through as trusted inference output. - let encrypted = BASE64.decode(payload).map_err(|_| { - Error::InvalidResponse("Inference SSE data was not valid encrypted payload".to_string()) - })?; - if encrypted.len() < 28 { - return Err(Error::InvalidResponse( - "Inference SSE data was shorter than the encrypted payload minimum".to_string(), - )); - } - let decrypted = crypto::decrypt_data(session_key, &encrypted) - .map_err(|error| Error::Decryption(format!("Failed to decrypt SSE data: {error}")))?; - - let mut transformed = BytesMut::with_capacity(prefix_len + decrypted.len() + line_ending.len()); - transformed.extend_from_slice(&content[..prefix_len]); - transformed.extend_from_slice(&decrypted); - transformed.extend_from_slice(line_ending); - Ok(transformed.freeze()) -} - -fn decrypt_sse_stream( - source: OpenSecretResponseBody, - session_key: [u8; 32], -) -> OpenSecretResponseBody { - decrypt_sse_stream_with_line_limit(source, session_key, MAX_INFERENCE_SSE_LINE_BYTES) -} - -fn decrypt_sse_stream_with_line_limit( - mut source: OpenSecretResponseBody, - session_key: [u8; 32], - max_line_bytes: usize, -) -> OpenSecretResponseBody { - let stream = async_stream::try_stream! { - let mut buffered = BytesMut::new(); - - while let Some(chunk) = source.next().await { - buffered.extend_from_slice(&chunk?); - while let Some(line_end) = buffered.iter().position(|byte| *byte == b'\n') { - let line_len = line_end + 1; - if line_len > max_line_bytes { - Err(Error::InvalidResponse(format!( - "Inference SSE line exceeds {max_line_bytes}-byte limit" - )))?; - } - let line = buffered.split_to(line_len).freeze(); - yield transform_sse_line(line, &session_key)?; - } - if buffered.len() > max_line_bytes { - Err(Error::InvalidResponse(format!( - "Inference SSE line exceeds {max_line_bytes}-byte limit" - )))?; - } - } - - if !buffered.is_empty() { - yield transform_sse_line(buffered.freeze(), &session_key)?; - } - }; - - Box::pin(stream) -} - -fn decrypt_sse_body(response: reqwest::Response, session_key: [u8; 32]) -> OpenSecretResponseBody { - let source = response - .bytes_stream() - .map(|chunk| chunk.map_err(Error::Http)); - decrypt_sse_stream(Box::pin(source), session_key) -} - async fn collect_response_body(mut body: OpenSecretResponseBody) -> Result { let mut collected = BytesMut::new(); while let Some(chunk) = body.next().await { @@ -424,49 +449,12 @@ async fn collect_response_body(mut body: OpenSecretResponseBody) -> Result Result { - let parsed = reqwest::Url::parse(base_url) - .map_err(|error| Error::Configuration(format!("Invalid base URL: {error}")))?; - if !matches!(parsed.scheme(), "http" | "https") { - return Err(Error::Configuration( - "Base URL must use HTTP or HTTPS".to_string(), - )); - } - if !parsed.username().is_empty() || parsed.password().is_some() { - return Err(Error::Configuration( - "Base URL must not contain credentials".to_string(), - )); - } - if parsed.query().is_some() || parsed.fragment().is_some() { - return Err(Error::Configuration( - "Base URL must not contain a query or fragment".to_string(), - )); - } - let host = parsed - .host_str() - .ok_or_else(|| Error::Configuration("Base URL must include a host".to_string()))?; - let host = host.trim_end_matches('.'); - - let is_mock_host = if host.eq_ignore_ascii_case("localhost") { - true - } else { - let address_host = host - .strip_prefix('[') - .and_then(|host| host.strip_suffix(']')) - .unwrap_or(host); - address_host.parse::().is_ok_and(|address| { - address.is_loopback() - || address.is_unspecified() - || (cfg!(target_os = "android") && address == IpAddr::from([10, 0, 2, 2])) - }) - }; - - if parsed.scheme() != "https" && !is_mock_host { - return Err(Error::Configuration( - "Non-local base URLs must use HTTPS".to_string(), - )); - } - Ok(is_mock_host) +fn random_cache_namespace_root() -> Result<[u8; 32]> { + let mut root = [0_u8; 32]; + OsRng + .try_fill_bytes(&mut root) + .map_err(|_| Error::Encryption("Secure randomness was unavailable".to_string()))?; + Ok(root) } impl OpenSecretClient { @@ -489,15 +477,19 @@ impl OpenSecretClient { pcr0_trust_policy: Pcr0TrustPolicy, ) -> Result { let base_url = base_url.into(); - let use_mock = uses_mock_attestation(&base_url)?; + let session_manager = SessionManager::new(); + let transport_v2 = TransportV2Client::new( + base_url, + pcr0_trust_policy, + random_cache_namespace_root()?, + session_manager.clone(), + )?; Ok(Self { - client: Client::new(), - base_url: base_url.trim_end_matches('/').to_string(), - session_manager: SessionManager::new(), + session_manager, + auth_commit_lock: StdMutex::new(()), refresh_lock: Mutex::new(()), - use_mock_attestation: use_mock, - pcr0_trust_policy, + transport_v2, }) } @@ -526,243 +518,158 @@ impl OpenSecretClient { pcr0_trust_policy: Pcr0TrustPolicy, ) -> Result { let base_url = base_url.into(); - let use_mock = uses_mock_attestation(&base_url)?; + let session_manager = SessionManager::new_with_api_key(api_key); + let transport_v2 = TransportV2Client::new( + base_url, + pcr0_trust_policy, + random_cache_namespace_root()?, + session_manager.clone(), + )?; Ok(Self { - client: Client::new(), - base_url: base_url.trim_end_matches('/').to_string(), - session_manager: SessionManager::new_with_api_key(api_key), + session_manager, + auth_commit_lock: StdMutex::new(()), refresh_lock: Mutex::new(()), - use_mock_attestation: use_mock, - pcr0_trust_policy, + transport_v2, }) } + /// Replace the random per-client provider-cache namespace root. + /// + /// Embedding applications may persist their own independently generated + /// 32-byte root and supply it during client construction. The SDK never + /// derives this value from a user identifier or API key. + #[must_use] + pub fn with_cache_namespace_root(mut self, root: TransportV2CacheNamespaceRoot) -> Self { + // Consuming the fresh client lets us replace the runtime without a + // fallible lock acquisition or leaving any sessions under the old root. + self.transport_v2 = self + .transport_v2 + .with_cache_namespace_root(root.into_bytes()); + self + } + pub fn set_api_key(&self, api_key: String) -> Result<()> { + self.transport_v2.clear_api_key_sessions()?; self.session_manager.set_api_key(api_key) } pub fn clear_api_key(&self) -> Result<()> { + self.transport_v2.clear_api_key_sessions()?; self.session_manager.clear_api_key() } pub async fn perform_attestation_handshake(&self) -> Result<()> { - // Generate a nonce - let nonce = Uuid::new_v4().to_string(); - - // Step 1: Get attestation document - let attestation_doc = self.get_attestation_document(&nonce).await?; - - // Step 2: Parse and verify attestation document - if !self.use_mock_attestation { - let verifier = AttestationVerifier::new(); - let doc = verifier - .verify_attestation_document(&attestation_doc.attestation_document, &nonce)?; - self.establish_session_from_verified_attestation(&nonce, doc) - .await - } else { - // For mock mode, extract without full verification - let doc = self.parse_mock_attestation(&attestation_doc.attestation_document)?; - self.establish_session_from_document(&nonce, doc).await - } + self.transport_v2.perform_attestation_handshake().await?; + Ok(()) } - /// Establish a session from a Nitro-authenticated document. - /// - /// Keeping PCR0 enforcement in the same path as key exchange makes the - /// fail-before-key-exchange ordering explicit and independently testable. - async fn establish_session_from_verified_attestation( - &self, - nonce: &str, - doc: AttestationDocument, - ) -> Result<()> { - let pcr0 = doc.pcrs.get(&0).ok_or_else(|| { - Error::AttestationVerificationFailed("Missing PCR0 in attestation document".to_string()) - })?; - self.pcr0_trust_policy.verify_pcr0(pcr0).await?; - self.establish_session_from_document(nonce, doc).await + pub fn get_session_id(&self) -> Result> { + self.transport_v2.active_session_id() } - async fn establish_session_from_document( - &self, - nonce: &str, - doc: AttestationDocument, - ) -> Result<()> { - let server_public_key = doc.public_key.ok_or_else(|| { - Error::AttestationVerificationFailed( - "No public key in attestation document".to_string(), + /// Establish and retain one anonymous attested session for native OAuth. + /// + /// This operation is accepted only on a credential-free client. The + /// returned identifier may be exposed to a hosted browser, but the session + /// keys remain inside this client and cannot be reconstructed from it. + pub async fn prepare_native_oauth_session(&self) -> Result { + let operation_credentials = self.native_oauth_anonymous_credentials()?; + let _guard = self.transport_v2.user_gate().lock().await; + self.v2_credentials_for_epoch(&operation_credentials.auth_epoch)?; + self.ensure_credential_generation(operation_credentials.generation)?; + self.native_oauth_anonymous_credentials()?; + + let session = self.transport_v2.perform_attestation_handshake().await?; + self.v2_credentials_for_epoch(&operation_credentials.auth_epoch)?; + self.ensure_credential_generation(operation_credentials.generation)?; + self.native_oauth_anonymous_credentials()?; + let current = self.transport_v2.anonymous_session()?.ok_or_else(|| { + Error::Session( + "Native OAuth session changed while attestation was in flight".to_string(), ) })?; + if !Arc::ptr_eq(¤t, &session) { + return Err(Error::Session( + "Native OAuth session changed while attestation was in flight".to_string(), + )); + } - // Step 3: Perform key exchange - self.perform_key_exchange(nonce, &server_public_key).await?; - - Ok(()) + Ok(NativeOAuthSessionInfo { + session_id: session.session_id(), + }) } - async fn get_attestation_document(&self, nonce: &str) -> Result { - let url = format!("{}/attestation/{}", self.base_url, nonce); - - let response = self.client.get(&url).send().await?; - - if !response.status().is_success() { - let status = response.status().as_u16(); - let text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - return Err(Error::Api { - status, - message: text, - }); + /// Redeem a backend-issued handoff grant through one exact native session. + /// + /// The request is sent once. Any ambiguous post-send failure is returned to + /// the caller and retires the exact session; callers must restart OAuth + /// rather than replaying the grant. On success, this client atomically + /// upgrades the retained anonymous session to the returned user authority. + pub async fn redeem_native_oauth_handoff( + &self, + expected_session_id: Uuid, + native_attempt_id: Uuid, + grant: NativeOAuthHandoffGrant, + ) -> Result { + if native_attempt_id.is_nil() { + return Err(Error::Authentication( + "Native OAuth attempt identifier must not be nil".to_string(), + )); } + let operation_credentials = self.native_oauth_anonymous_credentials()?; + let _guard = self.transport_v2.user_gate().lock().await; + self.v2_credentials_for_epoch(&operation_credentials.auth_epoch)?; + self.ensure_credential_generation(operation_credentials.generation)?; + self.native_oauth_anonymous_credentials()?; - response.json().await.map_err(Into::into) - } - - async fn perform_key_exchange(&self, nonce: &str, server_public_key: &[u8]) -> Result<()> { - // Generate ephemeral keypair - let (secret, public_key) = crypto::generate_static_keypair(); - let public_key_bytes = public_key.as_bytes(); - let public_key_b64 = BASE64.encode(public_key_bytes); + let session = self.transport_v2.anonymous_session()?.ok_or_else(|| { + Error::Authentication( + "Native OAuth attested session is unavailable; restart sign-in".to_string(), + ) + })?; + if session.session_id() != expected_session_id { + return Err(Error::Authentication( + "Native OAuth handoff did not match the retained attested session".to_string(), + )); + } + if session.is_expired()? { + self.transport_v2.clear_anonymous_session_if(&session)?; + return Err(Error::Authentication( + "Native OAuth attested session expired; restart sign-in".to_string(), + )); + } - // Send key exchange request - let url = format!("{}/key_exchange", self.base_url); - let body = KeyExchangeRequest { - client_public_key: public_key_b64, - nonce: nonce.to_string(), + let request = NativeOAuthHandoffRedeemRequest { + grant: grant.0.as_str(), + native_attempt_id, }; - - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - let response = self - .client - .post(&url) - .headers(headers) - .json(&body) - .send() + .v2_send_on_session( + &session, + "/auth/native-handoff/redeem", + "POST", + Some(serde_json::to_vec(&request)?), + Vec::new(), + V2SendOptions::transition( + ResponseMode::Unary, + None, + self.v2_cache_namespace_root()?, + ), + ) .await?; - if !response.status().is_success() { - let status = response.status().as_u16(); - let text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - return Err(Error::Api { - status, - message: text, - }); - } - - let key_exchange_response: KeyExchangeResponse = response.json().await?; - - // Convert server's public key bytes to x25519 PublicKey - let server_public_key = x25519_dalek::PublicKey::from( - <[u8; 32]>::try_from(server_public_key) - .map_err(|_| Error::KeyExchange("Invalid server public key length".to_string()))?, - ); - - // Perform ECDH to get shared secret - let shared_secret = crypto::perform_static_key_exchange(&secret, &server_public_key); - - // Decrypt the session key - let session_key = crypto::decrypt_session_key( - &shared_secret, - &key_exchange_response.encrypted_session_key, - )?; - - // Parse session_id as UUID - let session_id = Uuid::parse_str(&key_exchange_response.session_id) - .map_err(|e| Error::Session(format!("Invalid session ID format: {}", e)))?; - - self.session_manager.set_session(session_id, session_key)?; - - Ok(()) - } - - pub fn get_session_id(&self) -> Result> { - Ok(self.session_manager.get_session()?.map(|s| s.session_id)) - } - - fn parse_mock_attestation(&self, document_b64: &str) -> Result { - // For mock/dev mode, just extract the essential fields without full verification - let document_bytes = BASE64.decode(document_b64)?; - let cbor_value: CborValue = cbor::from_slice(&document_bytes)?; - - // Parse COSE_Sign1 structure - let cose_sign1 = match &cbor_value { - CborValue::Array(arr) if arr.len() == 4 => arr, - _ => { - return Err(Error::AttestationVerificationFailed( - "Invalid COSE_Sign1 structure".to_string(), - )) - } - }; - - // Extract payload - let payload = match &cose_sign1[2] { - CborValue::Bytes(b) => b, - _ => { - return Err(Error::AttestationVerificationFailed( - "Invalid payload".to_string(), - )) - } - }; - - // Parse attestation document from payload - let doc_cbor: CborValue = cbor::from_slice(payload)?; - let map = match &doc_cbor { - CborValue::Map(m) => m, - _ => { - return Err(Error::AttestationVerificationFailed( - "Invalid attestation document format".to_string(), - )) - } - }; - - // Extract public key (required for key exchange) - let mut public_key = None; - let mut nonce = None; - - for (key, value) in map { - if let CborValue::Text(key_str) = key { - match key_str.as_str() { - "public_key" => { - public_key = match value { - CborValue::Bytes(b) => Some(b.clone()), - _ => None, - }; - } - "nonce" => { - nonce = match value { - CborValue::Bytes(b) => Some(b.clone()), - _ => None, - }; - } - _ => {} - } - } - } - - // Return a minimal AttestationDocument with just what we need - Ok(AttestationDocument { - module_id: "mock-module".to_string(), - timestamp: 0, - digest: "SHA384".to_string(), - pcrs: std::collections::HashMap::new(), - certificate: vec![], - cabundle: vec![], - public_key, - user_data: None, - nonce, - }) + self.finish_v2_user_binding_transition( + &session, + &operation_credentials.auth_epoch, + response, + ) + .await } pub async fn test_connection(&self) -> Result { - let url = format!("{}/health-check", self.base_url); - let response = self.client.get(&url).send().await?; + let url = format!("{}/health-check", self.transport_v2.base_url()); + let response = self.transport_v2.http_client().get(&url).send().await?; if !response.status().is_success() { let status = response.status().as_u16(); @@ -785,8 +692,81 @@ impl OpenSecretClient { method: &str, data: Option, ) -> Result { - self.retry_encrypted_json_call_without_refresh(endpoint, method, data) - .await + let binding_transition = matches!( + endpoint, + "/login" + | "/register" + | "/auth/github/callback" + | "/auth/google/callback" + | "/auth/apple/callback" + | "/auth/apple/native" + ); + let expected_auth_epoch = binding_transition + .then(|| { + self.session_manager + .get_credential_snapshot() + .map(|credentials| credentials.auth_epoch) + }) + .transpose()?; + let body = data.map(|data| serde_json::to_vec(&data)).transpose()?; + let session = if binding_transition { + let _guard = self.transport_v2.user_gate().lock().await; + let expected_auth_epoch = expected_auth_epoch.as_ref().ok_or_else(|| { + Error::Session("Transport v2 authentication epoch is unavailable".to_string()) + })?; + self.v2_credentials_for_epoch(expected_auth_epoch)?; + let oauth_callback = matches!( + endpoint, + "/auth/github/callback" | "/auth/google/callback" | "/auth/apple/callback" + ); + let session = if oauth_callback { + let session = self.transport_v2.anonymous_session()?.ok_or_else(|| { + Error::Authentication( + "OAuth attested session is unavailable; restart sign-in".to_string(), + ) + })?; + if session.is_expired()? { + self.transport_v2.clear_anonymous_session_if(&session)?; + return Err(Error::Authentication( + "OAuth attested session expired; restart sign-in".to_string(), + )); + } + session + } else { + self.transport_v2.perform_attestation_handshake().await? + }; + let response = self + .v2_send_on_session( + &session, + endpoint, + method, + body, + Vec::new(), + V2SendOptions::transition( + ResponseMode::Unary, + None, + self.v2_cache_namespace_root()?, + ), + ) + .await?; + return self + .finish_v2_user_binding_transition(&session, expected_auth_epoch, response) + .await; + } else { + self.transport_v2.perform_attestation_handshake().await? + }; + + let response = self + .v2_send_on_session( + &session, + endpoint, + method, + body, + Vec::new(), + V2SendOptions::bound(ResponseMode::Unary), + ) + .await?; + Self::v2_decode_json_response(response).await } async fn authenticated_api_call( @@ -795,134 +775,377 @@ impl OpenSecretClient { method: &str, data: Option, ) -> Result { - self.retry_encrypted_json_call(endpoint, method, data, AuthHeaderMode::Jwt, true) + self.authenticated_api_call_with_auth(endpoint, method, data) .await + .map(|(value, _, _)| value) } - async fn retry_encrypted_json_call_without_refresh( + async fn authenticated_api_call_with_auth( &self, endpoint: &str, method: &str, data: Option, - ) -> Result { - let plaintext = data - .map(|data| serde_json::to_vec(&data).map(Bytes::from)) - .transpose()?; - let mut replayed = false; - let mut recovered_missing_session = false; - - let (response, session_key) = loop { - let auth = self.resolve_auth(AuthHeaderMode::None)?; - match self - .send_encrypted_request(endpoint, method, plaintext.as_deref(), &auth, false) - .await - { - Ok((response, session_key)) if response.status().is_success() => { - break (response, session_key) - } - Ok((response, _session_key)) => { - let recovery = - classify_response_recovery(response.status(), response.headers()); - if replayed || recovery != Some(RecoveryAction::Reattest) { - return Err(Self::api_error_from_response(response).await); - } + ) -> Result<(U, UserAuthEpoch, Arc)> { + let expected_auth_epoch = self.session_manager.get_credential_snapshot()?.auth_epoch; + let body = data.map(|data| serde_json::to_vec(&data)).transpose()?; + let (session, active_auth_epoch) = + self.ensure_v2_user_session(&expected_auth_epoch).await?; + let credentials = self.v2_credentials_for_epoch(&active_auth_epoch)?; + if !credentials + .user_session + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, &session)) + { + return Err(Error::Session( + "Credentials changed before the authenticated request was sent".to_string(), + )); + } + let response = self + .v2_send_on_session( + &session, + endpoint, + method, + body, + Vec::new(), + V2SendOptions::bound(ResponseMode::Unary), + ) + .await?; + let status = response.status(); + if matches!(status.as_u16(), 401 | 403) { + self.transport_v2.clear_user_session_if(&session)?; + } + let value = Self::v2_decode_json_response(response).await?; + Ok((value, active_auth_epoch, session)) + } - self.perform_attestation_handshake().await?; - replayed = true; - } - Err(Error::Session(_)) if !recovered_missing_session => { - self.perform_attestation_handshake().await?; - recovered_missing_session = true; - } - Err(error) => return Err(error), - } - }; + fn v2_cache_namespace_root(&self) -> Result { + Ok(CacheNamespaceRoot::from_bytes( + self.transport_v2.cache_namespace_root()?, + )) + } - Self::decrypt_json_response(response, session_key).await + fn native_oauth_anonymous_credentials(&self) -> Result { + let credentials = self.session_manager.get_credential_snapshot()?; + if credentials.tokens.is_some() + || credentials.api_key.is_some() + || credentials.user_session.is_some() + { + return Err(Error::Authentication( + "Native OAuth requires a dedicated credential-free client".to_string(), + )); + } + Ok(credentials) } - async fn retry_encrypted_json_call( + async fn finish_v2_user_binding_transition( &self, - endpoint: &str, - method: &str, - data: Option, - auth_mode: AuthHeaderMode, - allow_refresh: bool, + session: &Arc, + expected_auth_epoch: &UserAuthEpoch, + response: V2HttpResponse, ) -> Result { - let plaintext = data - .map(|data| serde_json::to_vec(&data).map(Bytes::from)) - .transpose()?; - let mut replayed = false; - let mut recovered_missing_session = false; + if !response.status().is_success() { + return Err(Self::v2_api_error_from_response(response).await); + } + // An authenticated success means the enclave committed this exact + // anonymous session's authority transition. Remove it from the + // anonymous slot before any fallible local response processing. + self.transport_v2.clear_anonymous_session_if(session)?; + let response_body = collect_response_body(response.into_body()).await?; + let (login, value, validated) = decode_v2_user_binding_response(&response_body)?; + let _commit_guard = self.auth_commit_guard()?; + if self + .session_manager + .replace_user_tokens_and_session_if_epoch( + expected_auth_epoch, + login.access_token, + Some(login.refresh_token), + validated.principal, + Arc::clone(session), + )? + .is_none() + { + return Err(Error::Session( + "Credentials changed while transport v2 authentication was in flight".to_string(), + )); + } + Ok(value) + } - let (response, session_key) = loop { - let auth = self.resolve_auth(auth_mode)?; - match self - .send_encrypted_request(endpoint, method, plaintext.as_deref(), &auth, false) - .await - { - Ok((response, session_key)) if response.status().is_success() => { - break (response, session_key) - } - Ok((response, _session_key)) => { - let recovery = - classify_response_recovery(response.status(), response.headers()); - if replayed { - return Err(Self::api_error_from_response(response).await); - } + fn auth_commit_guard(&self) -> Result> { + self.auth_commit_lock.lock().map_err(|_| { + Error::Authentication( + "Transport v2 authentication lifecycle state is unavailable".to_string(), + ) + }) + } - match recovery { - Some(RecoveryAction::Reattest) => { - self.perform_attestation_handshake().await?; - replayed = true; - } - Some(RecoveryAction::RefreshAccessToken) - if allow_refresh - && self - .recover_auth_after_unauthorized(auth_mode, &auth) - .await? => - { - replayed = true; - } - _ => return Err(Self::api_error_from_response(response).await), - } - } - Err(Error::Session(_)) if !recovered_missing_session => { - self.perform_attestation_handshake().await?; - recovered_missing_session = true; - } - Err(error) => return Err(error), + fn v2_credentials_for_epoch( + &self, + expected_auth_epoch: &UserAuthEpoch, + ) -> Result { + self.session_manager + .get_credential_snapshot_if_auth_epoch(expected_auth_epoch)? + .ok_or_else(|| { + Error::Session( + "Credentials changed while a transport v2 operation was waiting".to_string(), + ) + }) + } + + fn ensure_credential_generation(&self, expected_generation: u64) -> Result<()> { + if self + .session_manager + .credential_generation_matches(expected_generation)? + { + Ok(()) + } else { + Err(Error::Session( + "Credentials changed while a transport v2 operation was waiting".to_string(), + )) + } + } + + async fn v2_send_on_session( + &self, + session: &Arc, + endpoint: &str, + method: &str, + body: Option>, + mut headers: Vec, + options: V2SendOptions, + ) -> Result { + let logical_method = match method { + "GET" => LogicalMethod::Get, + "POST" => LogicalMethod::Post, + "PUT" => LogicalMethod::Put, + "PATCH" => LogicalMethod::Patch, + "DELETE" => LogicalMethod::Delete, + _ => { + return Err(Error::Configuration(format!( + "Unsupported logical HTTP method: {method}" + ))) } }; - - Self::decrypt_json_response(response, session_key).await + let uri: http::Uri = endpoint.parse().map_err(|error| { + Error::Configuration(format!("Invalid logical request URI: {error}")) + })?; + if uri.scheme().is_some() || uri.authority().is_some() { + return Err(Error::Configuration( + "Logical request URI must be origin-relative".to_string(), + )); + } + if body.is_some() { + headers.push(HeaderField::new( + header::CONTENT_TYPE.as_str(), + b"application/json".to_vec(), + )); + } + let request = LogicalRequest::new( + logical_method, + uri.path(), + uri.query().map(str::to_owned), + headers, + body, + ); + self.transport_v2 + .send_request( + session, + options.response_mode, + options.credential, + options.cache_namespace_root, + request, + ) + .await } - async fn decrypt_json_response( - response: reqwest::Response, - session_key: [u8; 32], - ) -> Result { - let encrypted_response: EncryptedResponse = response.json().await?; - let decrypted = - crypto::decrypt_data(&session_key, &BASE64.decode(&encrypted_response.encrypted)?)?; - Ok(serde_json::from_slice(&decrypted)?) + async fn v2_decode_json_response(response: V2HttpResponse) -> Result { + let status = response.status(); + if !status.is_success() { + return Err(Self::v2_api_error_from_response(response).await); + } + let body = collect_response_body(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) } - async fn api_error_from_response(response: reqwest::Response) -> Error { + async fn v2_api_error_from_response(response: V2HttpResponse) -> Error { let status = response.status().as_u16(); - let message = response - .text() + let message = collect_response_body(response.into_body()) .await - .unwrap_or_else(|_| "Unknown error".to_string()); + .map(|body| String::from_utf8_lossy(&body).into_owned()) + .unwrap_or_else(|_| "Authenticated application error".to_string()); Error::Api { status, message } } + async fn ensure_v2_user_session( + &self, + expected_auth_epoch: &UserAuthEpoch, + ) -> Result<(Arc, UserAuthEpoch)> { + let credentials = self.v2_credentials_for_epoch(expected_auth_epoch)?; + if let Some(session) = credentials.user_session.as_ref() { + if !session.is_expired()? && self.v2_user_binding_is_fresh(&credentials)? { + return Ok((Arc::clone(session), credentials.auth_epoch)); + } + self.transport_v2.clear_user_session_if(session)?; + } + + let _guard = self.transport_v2.user_gate().lock().await; + let credentials = self.v2_credentials_for_epoch(expected_auth_epoch)?; + if let Some(session) = credentials.user_session.as_ref() { + if !session.is_expired()? && self.v2_user_binding_is_fresh(&credentials)? { + return Ok((Arc::clone(session), credentials.auth_epoch)); + } + self.transport_v2.clear_user_session_if(session)?; + } + self.resume_v2_user_session(expected_auth_epoch).await + } + + fn v2_user_binding_is_fresh(&self, credentials: &CredentialSnapshot) -> Result { + let Some(tokens) = credentials.tokens.as_ref() else { + return Ok(false); + }; + let Some(refresh_token) = tokens.refresh_token.as_deref() else { + return Ok(false); + }; + let validated = validate_v2_user_token_pair(&tokens.access_token, refresh_token)?; + if credentials.auth_epoch.principal.as_deref() != Some(validated.principal.as_str()) { + return Ok(false); + } + let now_unix_seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| { + Error::Authentication( + "System clock is before the Unix epoch; transport v2 cannot validate the user binding" + .to_string(), + ) + })? + .as_secs(); + Ok(v2_user_binding_is_fresh_at( + validated.access_expires_at_unix_seconds, + now_unix_seconds, + )) + } + + async fn resume_v2_user_session( + &self, + expected_auth_epoch: &UserAuthEpoch, + ) -> Result<(Arc, UserAuthEpoch)> { + let credentials = self.v2_credentials_for_epoch(expected_auth_epoch)?; + let expected_principal = credentials.auth_epoch.principal.clone().ok_or_else(|| { + Error::Authentication( + "Transport v2 requires a fresh login or resumption credential".to_string(), + ) + })?; + let refresh_token = credentials + .tokens + .as_ref() + .and_then(|tokens| tokens.refresh_token.as_deref()) + .ok_or_else(|| { + Error::Authentication( + "Transport v2 requires a fresh login or resumption credential".to_string(), + ) + })?; + let credential = Credential::resumption(refresh_token.as_bytes().to_vec()); + let session = self.transport_v2.fresh_session().await?; + let response = self + .v2_send_on_session( + &session, + "/refresh", + "POST", + None, + Vec::new(), + V2SendOptions::transition( + ResponseMode::Unary, + Some(credential), + self.v2_cache_namespace_root()?, + ), + ) + .await?; + if !response.status().is_success() { + if matches!(response.status().as_u16(), 401 | 403) { + let _commit_guard = self.auth_commit_guard()?; + if !self + .session_manager + .invalidate_user_auth_if_epoch(&credentials.auth_epoch)? + { + return Err(Error::Session( + "Credentials changed while rejected transport v2 resumption was in flight" + .to_string(), + )); + } + return Err(Error::Authentication( + "Stored credentials cannot resume transport v2; sign in again".to_string(), + )); + } + return Err(Self::v2_api_error_from_response(response).await); + } + let body = match collect_response_body(response.into_body()).await { + Ok(body) => body, + Err(error) => { + let _commit_guard = self.auth_commit_guard()?; + self.session_manager + .invalidate_user_auth_if_epoch(&credentials.auth_epoch)?; + return Err(error); + } + }; + let response: RefreshResponse = match serde_json::from_slice(&body) { + Ok(response) => response, + Err(error) => { + let _commit_guard = self.auth_commit_guard()?; + self.session_manager + .invalidate_user_auth_if_epoch(&credentials.auth_epoch)?; + return Err(error.into()); + } + }; + let validated = + match validate_v2_user_token_pair(&response.access_token, &response.refresh_token) { + Ok(validated) => validated, + Err(error) => { + let _commit_guard = self.auth_commit_guard()?; + self.session_manager + .invalidate_user_auth_if_epoch(&credentials.auth_epoch)?; + return Err(error.into()); + } + }; + if validated.principal != expected_principal { + let _commit_guard = self.auth_commit_guard()?; + if !self + .session_manager + .invalidate_user_auth_if_epoch(&credentials.auth_epoch)? + { + return Err(Error::Session( + "Credentials changed while transport v2 resumption was in flight".to_string(), + )); + } + return Err(Error::Authentication( + "Transport v2 resumption changed the authenticated principal".to_string(), + )); + } + let _commit_guard = self.auth_commit_guard()?; + let Some(active_auth_epoch) = self + .session_manager + .replace_user_tokens_and_session_if_epoch( + &credentials.auth_epoch, + response.access_token, + Some(response.refresh_token), + validated.principal, + Arc::clone(&session), + )? + else { + return Err(Error::Session( + "Credentials changed while transport v2 resumption was in flight".to_string(), + )); + }; + Ok((session, active_auth_epoch)) + } + /// Sends a lossless encrypted request to an OpenSecret inference endpoint. /// /// The request URI must be relative and target one of the SDK's explicitly /// allowed inference routes. Its method, query string, headers, and body - /// bytes are otherwise caller-owned. The SDK does not parse the request as - /// JSON and never adds or changes inference parameters such as `stream`. + /// bytes are otherwise caller-owned. For chat completions only, the SDK + /// reads the top-level boolean `stream` selector so the authenticated + /// transport can commit to unary or streaming response reconstruction. It + /// never adds or changes inference parameters. /// /// OpenSecret authentication, attestation sessions, and the encrypted /// envelope remain SDK-owned. Caller-provided `Host`, `Authorization`, @@ -932,14 +1155,14 @@ impl OpenSecretClient { /// Other headers are preserved. /// /// The returned HTTP response preserves the final OpenSecret status and - /// safe response headers. Its body is decrypted raw bytes. SSE framing is - /// preserved while encrypted `data:` fields are decrypted without parsing - /// their contents as completion JSON. Individual SSE lines are limited to - /// 16 MiB. + /// safe response headers. Its body is the exact authenticated logical body; + /// application SSE framing and chunks are preserved without parsing + /// completion JSON. pub async fn send_inference_request( &self, request: InferenceRequest, ) -> Result { + let operation_credentials = self.session_manager.get_credential_snapshot()?; let (parts, body) = request.into_parts(); if parts.uri.scheme().is_some() || parts.uri.authority().is_some() { return Err(Error::Configuration( @@ -954,154 +1177,161 @@ impl OpenSecretClient { ))); } - let path_and_query = parts + let response_mode = inference_response_mode(parts.uri.path(), &body)?; + let headers = sanitize_inference_request_headers(&parts.headers) + .iter() + .map(|(name, value)| HeaderField::new(name.as_str(), value.as_bytes().to_vec())) + .collect::>(); + let endpoint = parts .uri .path_and_query() .ok_or_else(|| Error::Configuration("Inference request URI has no path".to_string()))? - .as_str() - .to_string(); - let headers = sanitize_inference_request_headers(&parts.headers); - let mut replayed = false; - let mut recovered_missing_session = false; - - loop { - let auth = self.resolve_auth(AuthHeaderMode::ApiKeyOrJwt)?; - let result = self - .send_inference_request_once( - &parts.method, - &path_and_query, - &headers, - body.clone(), - &auth, + .as_str(); + let body = (!body.is_empty()).then(|| body.to_vec()); + let has_user_credentials = operation_credentials.tokens.is_some(); + + let response = if operation_credentials.api_key.is_some() { + self.v2_api_key_inference_call( + &operation_credentials, + endpoint, + parts.method.as_str(), + body, + headers, + response_mode, + ) + .await? + } else { + let session = + if should_use_anonymous_models_session(parts.uri.path(), has_user_credentials) { + let session = self.transport_v2.perform_attestation_handshake().await?; + self.ensure_credential_generation(operation_credentials.generation)?; + session + } else { + let (session, active_auth_epoch) = self + .ensure_v2_user_session(&operation_credentials.auth_epoch) + .await?; + let credentials = self.v2_credentials_for_epoch(&active_auth_epoch)?; + if credentials.api_key.is_some() + || !credentials + .user_session + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, &session)) + { + return Err(Error::Session( + "Credentials changed before the inference request was sent".to_string(), + )); + } + session + }; + let response = self + .v2_send_on_session( + &session, + endpoint, + parts.method.as_str(), + body, + headers, + V2SendOptions::bound(response_mode), ) - .await; + .await?; + if matches!(response.status().as_u16(), 401 | 403) { + self.transport_v2.clear_user_session_if(&session)?; + } + response + }; - match result { - Ok((response, session_key)) if response.status().is_success() => { - return self.finish_inference_response(response, session_key).await - } - Ok((response, session_key)) => { - let recovery = - classify_response_recovery(response.status(), response.headers()); - if replayed { - return self.finish_inference_response(response, session_key).await; - } + Self::finish_v2_inference_response(response) + } - match recovery { - Some(RecoveryAction::Reattest) => { - self.perform_attestation_handshake().await?; - replayed = true; - } - Some(RecoveryAction::RefreshAccessToken) => { - if matches!( - self.recover_auth_after_unauthorized( - AuthHeaderMode::ApiKeyOrJwt, - &auth, - ) - .await, - Ok(true) - ) { - replayed = true; - } else { - return self.finish_inference_response(response, session_key).await; - } - } - None => return self.finish_inference_response(response, session_key).await, - } + async fn v2_api_key_inference_call( + &self, + operation_credentials: &CredentialSnapshot, + endpoint: &str, + method: &str, + body: Option>, + headers: Vec, + response_mode: ResponseMode, + ) -> Result { + let api_key = operation_credentials.api_key.as_deref().ok_or_else(|| { + Error::Session("API key authority changed before inference was sent".to_string()) + })?; + let expected_generation = operation_credentials.generation; + let fingerprint: [u8; 32] = Sha256::digest(api_key.as_bytes()).into(); + let scope = ApiKeyScope::new(fingerprint); + + if let Some(session) = self.transport_v2.api_key_session(&scope)? { + if !session.is_expired()? { + self.ensure_credential_generation(expected_generation)?; + let response = self + .v2_send_on_session( + &session, + endpoint, + method, + body, + headers, + V2SendOptions::bound(response_mode), + ) + .await?; + if matches!(response.status().as_u16(), 401 | 403) { + self.transport_v2 + .clear_api_key_session_if(&scope, &session)?; } - Err(Error::Session(_)) if !recovered_missing_session => { - self.perform_attestation_handshake().await?; - recovered_missing_session = true; + return Ok(response); + } + self.transport_v2 + .clear_api_key_session_if(&scope, &session)?; + } + + let _guard = self.transport_v2.api_key_gate().lock().await; + if let Some(session) = self.transport_v2.api_key_session(&scope)? { + if !session.is_expired()? { + self.ensure_credential_generation(expected_generation)?; + let response = self + .v2_send_on_session( + &session, + endpoint, + method, + body, + headers, + V2SendOptions::bound(response_mode), + ) + .await?; + if matches!(response.status().as_u16(), 401 | 403) { + self.transport_v2 + .clear_api_key_session_if(&scope, &session)?; } - Err(error) => return Err(error), + return Ok(response); } + self.transport_v2 + .clear_api_key_session_if(&scope, &session)?; } - } - async fn send_inference_request_once( - &self, - method: &http::Method, - path_and_query: &str, - caller_headers: &HttpHeaderMap, - body: Bytes, - auth: &ResolvedAuth, - ) -> Result<(reqwest::Response, [u8; 32])> { - let session = self.session_manager.get_session()?.ok_or_else(|| { - Error::Session( - "No active session. Call perform_attestation_handshake first".to_string(), + let session = self.transport_v2.fresh_session().await?; + self.ensure_credential_generation(expected_generation)?; + let response = self + .v2_send_on_session( + &session, + endpoint, + method, + body, + headers, + V2SendOptions::transition( + response_mode, + Some(Credential::api_key(api_key.as_bytes().to_vec())), + self.v2_cache_namespace_root()?, + ), ) - })?; - let url = format!("{}{}", self.base_url, path_and_query); - let mut headers = caller_headers.clone(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - headers.insert( - "x-session-id", - HeaderValue::from_str(&session.session_id.to_string()) - .map_err(|error| Error::Session(format!("Invalid session ID: {error}")))?, - ); - if let Some(token) = &auth.token { - headers.insert( - AUTHORIZATION, - HeaderValue::from_str(&format!("Bearer {token}")).map_err(|error| { - Error::Authentication(format!( - "Invalid authorization credential format: {error}" - )) - })?, - ); + .await?; + if response.status().is_success() { + self.transport_v2 + .set_api_key_session(&scope, Arc::clone(&session))?; } - - let encrypted_body = if body.is_empty() { - None - } else { - let encrypted = crypto::encrypt_data(&session.session_key, &body)?; - Some(EncryptedRequest { - encrypted: BASE64.encode(encrypted), - }) - }; - let request = self.client.request(method.clone(), url).headers(headers); - let response = match encrypted_body { - Some(encrypted_body) => request.json(&encrypted_body).send().await?, - None => request.send().await?, - }; - - Ok((response, session.session_key)) + Ok(response) } - async fn finish_inference_response( - &self, - response: reqwest::Response, - session_key: [u8; 32], - ) -> Result> { - let status = response.status(); - let headers = sanitize_inference_response_headers(response.headers()); - let body: OpenSecretResponseBody = if is_event_stream(&headers) { - decrypt_sse_body(response, session_key) - } else { - let raw_body = response.bytes().await?; - let decrypted_body = match serde_json::from_slice::(&raw_body) { - Ok(encrypted_body) => { - let encrypted = BASE64.decode(encrypted_body.encrypted)?; - Bytes::from(crypto::decrypt_data(&session_key, &encrypted)?) - } - Err(_) if status.is_success() && !raw_body.is_empty() => { - return Err(Error::InvalidResponse( - "Successful inference response did not contain an encrypted body" - .to_string(), - )); - } - Err(_) => raw_body, - }; - Box::pin(futures::stream::once(async move { Ok(decrypted_body) })) - }; - - let mut result = HttpResponse::builder() - .status(status) - .body(body) - .map_err(|error| { - Error::InvalidResponse(format!("Failed to construct inference response: {error}")) - })?; - *result.headers_mut() = headers; - Ok(result) + fn finish_v2_inference_response(response: V2HttpResponse) -> Result { + let (mut parts, body) = response.into_parts(); + parts.headers = sanitize_inference_response_headers(&parts.headers); + Ok(HttpResponse::from_parts(parts, body)) } /// Typed compatibility wrapper over the lossless inference transport. @@ -1139,175 +1369,6 @@ impl OpenSecretClient { Ok(serde_json::from_slice(&body)?) } - async fn retry_encrypted_stream_call( - &self, - endpoint: &str, - method: &str, - data: Option, - auth_mode: AuthHeaderMode, - allow_refresh: bool, - ) -> Result<(reqwest::Response, [u8; 32])> { - let plaintext = data - .map(|data| serde_json::to_vec(&data).map(Bytes::from)) - .transpose()?; - let mut replayed = false; - let mut recovered_missing_session = false; - - loop { - let auth = self.resolve_auth(auth_mode)?; - match self - .send_encrypted_request(endpoint, method, plaintext.as_deref(), &auth, true) - .await - { - Ok((response, session_key)) if response.status().is_success() => { - return Ok((response, session_key)) - } - Ok((response, _session_key)) => { - let recovery = - classify_response_recovery(response.status(), response.headers()); - if replayed { - return Err(Self::api_error_from_response(response).await); - } - - match recovery { - Some(RecoveryAction::Reattest) => { - self.perform_attestation_handshake().await?; - replayed = true; - } - Some(RecoveryAction::RefreshAccessToken) - if allow_refresh - && self - .recover_auth_after_unauthorized(auth_mode, &auth) - .await? => - { - replayed = true; - } - _ => return Err(Self::api_error_from_response(response).await), - } - } - Err(Error::Session(_)) if !recovered_missing_session => { - self.perform_attestation_handshake().await?; - recovered_missing_session = true; - } - Err(error) => return Err(error), - } - } - } - - async fn send_encrypted_request( - &self, - endpoint: &str, - method: &str, - plaintext: Option<&[u8]>, - auth: &ResolvedAuth, - accept_sse: bool, - ) -> Result<(reqwest::Response, [u8; 32])> { - let session = self.session_manager.get_session()?.ok_or_else(|| { - Error::Session( - "No active session. Call perform_attestation_handshake first".to_string(), - ) - })?; - - let url = format!("{}{}", self.base_url, endpoint); - - let encrypted_body = if let Some(plaintext) = plaintext { - let encrypted = crypto::encrypt_data(&session.session_key, plaintext)?; - Some(EncryptedRequest { - encrypted: BASE64.encode(&encrypted), - }) - } else { - None - }; - - let headers = self.build_encrypted_headers(&session, auth, accept_sse)?; - let request_builder = match method { - "GET" => self.client.get(&url), - "POST" => self.client.post(&url), - "PUT" => self.client.put(&url), - "DELETE" => self.client.delete(&url), - _ => { - return Err(Error::Api { - status: 0, - message: format!("Unsupported HTTP method: {}", method), - }) - } - }; - - let request_builder = request_builder.headers(headers); - let response = if let Some(body) = encrypted_body { - request_builder.json(&body).send().await? - } else { - request_builder.send().await? - }; - - Ok((response, session.session_key)) - } - - fn build_encrypted_headers( - &self, - session: &crate::types::SessionState, - auth: &ResolvedAuth, - accept_sse: bool, - ) -> Result { - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - - if accept_sse { - headers.insert("accept", HeaderValue::from_static("text/event-stream")); - } - - headers.insert( - "x-session-id", - HeaderValue::from_str(&session.session_id.to_string()) - .map_err(|e| Error::Session(format!("Invalid session ID: {}", e)))?, - ); - - if let Some(token) = &auth.token { - headers.insert( - AUTHORIZATION, - HeaderValue::from_str(&format!("Bearer {}", token)).map_err(|e| { - Error::Authentication(format!("Invalid authorization credential format: {}", e)) - })?, - ); - } - - Ok(headers) - } - - fn resolve_auth(&self, auth_mode: AuthHeaderMode) -> Result { - let credentials = self.session_manager.get_credential_snapshot()?; - match auth_mode { - AuthHeaderMode::None => Ok(ResolvedAuth { - token: None, - using_api_key: false, - generation: 0, - }), - AuthHeaderMode::Jwt => Ok(ResolvedAuth { - token: credentials - .tokens - .as_ref() - .map(|tokens| tokens.access_token.clone()), - using_api_key: false, - generation: credentials.token_generation, - }), - AuthHeaderMode::ApiKeyOrJwt => { - if let Some(api_key) = credentials.api_key { - Ok(ResolvedAuth { - token: Some(api_key), - using_api_key: true, - generation: credentials.api_key_generation, - }) - } else { - Ok(ResolvedAuth { - token: credentials.tokens.map(|tokens| tokens.access_token), - using_api_key: false, - generation: credentials.token_generation, - }) - } - } - } - } - // Auth Methods pub async fn login( &self, @@ -1322,17 +1383,8 @@ impl OpenSecretClient { client_id, }; - let response: LoginResponse = self - .encrypted_api_call("/login", "POST", Some(credentials)) - .await?; - - // Store the tokens - self.session_manager.set_tokens( - response.access_token.clone(), - Some(response.refresh_token.clone()), - )?; - - Ok(response) + self.encrypted_api_call("/login", "POST", Some(credentials)) + .await } pub async fn login_with_id( @@ -1348,17 +1400,8 @@ impl OpenSecretClient { client_id, }; - let response: LoginResponse = self - .encrypted_api_call("/login", "POST", Some(credentials)) - .await?; - - // Store the tokens - self.session_manager.set_tokens( - response.access_token.clone(), - Some(response.refresh_token.clone()), - )?; - - Ok(response) + self.encrypted_api_call("/login", "POST", Some(credentials)) + .await } pub async fn register( @@ -1375,17 +1418,8 @@ impl OpenSecretClient { client_id, }; - let response: LoginResponse = self - .encrypted_api_call("/register", "POST", Some(credentials)) - .await?; - - // Store the tokens - self.session_manager.set_tokens( - response.access_token.clone(), - Some(response.refresh_token.clone()), - )?; - - Ok(response) + self.encrypted_api_call("/register", "POST", Some(credentials)) + .await } pub async fn register_guest(&self, password: String, client_id: Uuid) -> Result { @@ -1396,17 +1430,8 @@ impl OpenSecretClient { client_id, }; - let response: LoginResponse = self - .encrypted_api_call("/register", "POST", Some(credentials)) - .await?; - - // Store the tokens - self.session_manager.set_tokens( - response.access_token.clone(), - Some(response.refresh_token.clone()), - )?; - - Ok(response) + self.encrypted_api_call("/register", "POST", Some(credentials)) + .await } // OAuth Methods @@ -1436,16 +1461,8 @@ impl OpenSecretClient { invite_code, }; - let response: LoginResponse = self - .encrypted_api_call("/auth/github/callback", "POST", Some(request)) - .await?; - - self.session_manager.set_tokens( - response.access_token.clone(), - Some(response.refresh_token.clone()), - )?; - - Ok(response) + self.encrypted_api_call("/auth/github/callback", "POST", Some(request)) + .await } pub async fn initiate_google_auth( @@ -1473,16 +1490,8 @@ impl OpenSecretClient { invite_code, }; - let response: LoginResponse = self - .encrypted_api_call("/auth/google/callback", "POST", Some(request)) - .await?; - - self.session_manager.set_tokens( - response.access_token.clone(), - Some(response.refresh_token.clone()), - )?; - - Ok(response) + self.encrypted_api_call("/auth/google/callback", "POST", Some(request)) + .await } pub async fn initiate_apple_auth( @@ -1510,16 +1519,8 @@ impl OpenSecretClient { invite_code, }; - let response: LoginResponse = self - .encrypted_api_call("/auth/apple/callback", "POST", Some(request)) - .await?; - - self.session_manager.set_tokens( - response.access_token.clone(), - Some(response.refresh_token.clone()), - )?; - - Ok(response) + self.encrypted_api_call("/auth/apple/callback", "POST", Some(request)) + .await } #[allow(clippy::too_many_arguments)] @@ -1545,79 +1546,46 @@ impl OpenSecretClient { invite_code, }; - let response: LoginResponse = self - .encrypted_api_call("/auth/apple/native", "POST", Some(request)) - .await?; - - self.session_manager.set_tokens( - response.access_token.clone(), - Some(response.refresh_token.clone()), - )?; - - Ok(response) - } - - async fn refresh_token_inner(&self) -> Result<()> { - let credentials = self.session_manager.get_credential_snapshot()?; - let refresh_token = credentials - .tokens - .as_ref() - .and_then(|tokens| tokens.refresh_token.clone()) - .ok_or_else(|| Error::Authentication("No refresh token available".to_string()))?; - - let request = RefreshRequest { refresh_token }; - - let response: RefreshResponse = self - .encrypted_api_call("/refresh", "POST", Some(request)) - .await?; - - // A synchronous set_tokens or a logout/clear may have replaced these - // credentials while the HTTP refresh was in flight. Drop this stale - // response instead of reinstalling credentials the caller superseded. - self.session_manager.set_tokens_if_generation( - credentials.token_generation, - response.access_token, - Some(response.refresh_token), - )?; - - Ok(()) + self.encrypted_api_call("/auth/apple/native", "POST", Some(request)) + .await } - async fn recover_auth_after_unauthorized( - &self, - auth_mode: AuthHeaderMode, - failed_auth: &ResolvedAuth, - ) -> Result { - let _refresh_guard = self.refresh_lock.lock().await; - - // Another request or the application may already have replaced the - // exact credential source while this request was in flight or waiting - // for the refresh lock. Retry with that replacement, including an API - // key/JWT source switch, rather than refreshing the wrong credential. - let current_auth = self.resolve_auth(auth_mode)?; - if current_auth != *failed_auth { - return Ok(true); - } - - if current_auth.using_api_key { - return Ok(false); + async fn refresh_token_inner(&self, expected_auth_epoch: &UserAuthEpoch) -> Result<()> { + let _guard = self.transport_v2.user_gate().lock().await; + let credentials = self.v2_credentials_for_epoch(expected_auth_epoch)?; + if let Some(session) = credentials.user_session.as_ref() { + self.transport_v2.clear_user_session_if(session)?; } - - self.refresh_token_inner().await?; - Ok(true) + self.resume_v2_user_session(expected_auth_epoch).await?; + Ok(()) } pub async fn refresh_token(&self) -> Result<()> { + let expected_auth_epoch = self.session_manager.get_credential_snapshot()?.auth_epoch; let _refresh_guard = self.refresh_lock.lock().await; - self.refresh_token_inner().await + self.refresh_token_inner(&expected_auth_epoch).await } - async fn logout_inner(&self, push_device_id: Option) -> Result<()> { + async fn logout_inner( + &self, + push_device_id: Option, + expected_auth_epoch: &UserAuthEpoch, + ) -> Result<()> { // Serialize logout with refresh so an internal token rotation cannot - // race the clear. Application-supplied credentials remain lock-free - // and win through the generation check below. + // race the clear. Application-supplied credentials use the short + // commit lock and win through the generation check below. let _refresh_guard = self.refresh_lock.lock().await; - let credentials = self.session_manager.get_credential_snapshot()?; + let (session, active_auth_epoch) = self.ensure_v2_user_session(expected_auth_epoch).await?; + let credentials = self.v2_credentials_for_epoch(&active_auth_epoch)?; + if !credentials + .user_session + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, &session)) + { + return Err(Error::Session( + "Credentials changed before logout was sent".to_string(), + )); + } let refresh_token = credentials .tokens .as_ref() @@ -1629,24 +1597,47 @@ impl OpenSecretClient { push_device_id, }; - let _: serde_json::Value = self - .encrypted_api_call("/logout", "POST", Some(request)) + let response = self + .v2_send_on_session( + &session, + "/logout", + "POST", + Some(serde_json::to_vec(&request)?), + Vec::new(), + V2SendOptions::bound(ResponseMode::Unary), + ) .await?; + let status = response.status(); + if matches!(status.as_u16(), 401 | 403) { + self.transport_v2.clear_user_session_if(&session)?; + } + let _: serde_json::Value = Self::v2_decode_json_response(response).await?; // Do not clear credentials installed by the application while the // logout request was in flight (for example, a rapid account switch). - self.session_manager + let _commit_guard = self.auth_commit_guard()?; + let credentials_cleared = self + .session_manager .clear_all_if_generation(credentials.generation)?; + // The backend closes the exact authenticated session on successful + // logout. Pointer-scoped cleanup cannot clear a newer account switch. + self.transport_v2.clear_user_session_if(&session)?; + if credentials_cleared { + self.transport_v2.clear_api_key_sessions()?; + } Ok(()) } pub async fn logout(&self) -> Result<()> { - self.logout_inner(None).await + let expected_auth_epoch = self.session_manager.get_credential_snapshot()?.auth_epoch; + self.logout_inner(None, &expected_auth_epoch).await } pub async fn logout_with_push_device_id(&self, push_device_id: Uuid) -> Result<()> { - self.logout_inner(Some(push_device_id)).await + let expected_auth_epoch = self.session_manager.get_credential_snapshot()?.auth_epoch; + self.logout_inner(Some(push_device_id), &expected_auth_epoch) + .await } pub fn get_access_token(&self) -> Result> { @@ -1667,8 +1658,64 @@ impl OpenSecretClient { } pub fn set_tokens(&self, access_token: String, refresh_token: Option) -> Result<()> { + let refresh_token = refresh_token.ok_or_else(|| { + Error::Authentication( + "Transport v2 requires paired access and resumption descriptors".to_string(), + ) + })?; + let validated = validate_v2_user_token_pair(&access_token, &refresh_token)?; + let _commit_guard = self.auth_commit_guard()?; self.session_manager.clear_session()?; - self.session_manager.set_tokens(access_token, refresh_token) + self.session_manager + .replace_user_tokens(access_token, Some(refresh_token), Some(validated.principal)) + .map(|_| ()) + } + + /// Import the opaque transport-v2 authentication bundle produced by the + /// browser SDK for this exact configured backend. + pub fn import_transport_v2_auth_bundle(&self, bundle: &str) -> Result<()> { + let decoded = decode_auth_bundle(bundle, self.transport_v2.base_url())?; + let validated = validate_v2_user_token_pair(&decoded.access_token, &decoded.refresh_token)?; + let _commit_guard = self.auth_commit_guard()?; + let expected = self.session_manager.get_credential_snapshot()?.auth_epoch; + self.transport_v2 + .replace_cache_namespace_root(decoded.cache_namespace_root)?; + if self + .session_manager + .replace_user_tokens_if_epoch( + &expected, + decoded.access_token.clone(), + Some(decoded.refresh_token.clone()), + validated.principal, + )? + .is_none() + { + return Err(Error::Session( + "Credentials changed while transport v2 authentication was imported".to_string(), + )); + } + Ok(()) + } + + /// Export the current transport-v2 descriptors and cache root as one + /// opaque, origin-bound bundle. + pub fn export_transport_v2_auth_bundle(&self) -> Result> { + let _commit_guard = self.auth_commit_guard()?; + let Some(tokens) = self.session_manager.get_tokens()? else { + return Ok(None); + }; + let Some(refresh_token) = tokens.refresh_token.as_deref() else { + return Ok(None); + }; + let root = self.transport_v2.cache_namespace_root()?; + encode_auth_bundle( + self.transport_v2.base_url(), + &tokens.access_token, + refresh_token, + &root, + ) + .map(Some) + .map_err(Into::into) } // User Profile API @@ -1903,16 +1950,68 @@ impl OpenSecretClient { current_password, new_password, }; - let response: CredentialUpdateResponse = self - .authenticated_api_call("/protected/change_password", "POST", Some(request)) + let (response, auth_epoch, session): ( + CredentialUpdateResponse, + UserAuthEpoch, + Arc, + ) = self + .authenticated_api_call_with_auth("/protected/change_password", "POST", Some(request)) .await?; - if let Some(access_token) = response.access_token { - let refresh_token = match response.refresh_token { - Some(refresh_token) => Some(refresh_token), - None => self.session_manager.get_refresh_token()?, - }; - self.session_manager - .set_tokens(access_token, refresh_token)?; + let _commit_guard = self.auth_commit_guard()?; + match (response.access_token, response.refresh_token) { + (Some(access_token), Some(refresh_token)) => { + let validated = match validate_v2_user_token_pair(&access_token, &refresh_token) { + Ok(validated) => validated, + Err(error) => { + self.session_manager + .invalidate_user_auth_if_epoch(&auth_epoch)?; + self.transport_v2.clear_user_session_if(&session)?; + return Err(error.into()); + } + }; + if auth_epoch.principal.as_deref() != Some(validated.principal.as_str()) { + if !self + .session_manager + .invalidate_user_auth_if_epoch(&auth_epoch)? + { + return Err(Error::Session( + "Credentials changed while password change was in flight".to_string(), + )); + } + self.transport_v2.clear_user_session_if(&session)?; + return Err(Error::Authentication( + "Password change returned descriptors for another principal".to_string(), + )); + } + if self + .session_manager + .replace_user_tokens_if_epoch( + &auth_epoch, + access_token, + Some(refresh_token), + validated.principal, + )? + .is_none() + { + return Err(Error::Session( + "Credentials changed while password change was in flight".to_string(), + )); + } + } + _ => { + if !self + .session_manager + .invalidate_user_auth_if_epoch(&auth_epoch)? + { + return Err(Error::Session( + "Credentials changed while password change was in flight".to_string(), + )); + } + return Err(Error::Authentication( + "Password changed without replacement transport-v2 descriptors; sign in again" + .to_string(), + )); + } } Ok(()) } @@ -1996,9 +2095,22 @@ impl OpenSecretClient { confirmation_code, plaintext_secret, }; - let _: serde_json::Value = self - .authenticated_api_call("/protected/delete-account/confirm", "POST", Some(request)) + let (_, auth_epoch, session): (serde_json::Value, UserAuthEpoch, Arc) = self + .authenticated_api_call_with_auth( + "/protected/delete-account/confirm", + "POST", + Some(request), + ) .await?; + let _commit_guard = self.auth_commit_guard()?; + let invalidated = self + .session_manager + .invalidate_user_auth_if_epoch(&auth_epoch)?; + self.transport_v2.clear_user_session_if(&session)?; + if invalidated { + self.transport_v2 + .replace_cache_namespace_root(random_cache_namespace_root()?)?; + } Ok(()) } @@ -2237,7 +2349,7 @@ impl OpenSecretClient { request.stream_options = Some(StreamOptions { include_usage: true, }); - use eventsource_stream::Eventsource; + use eventsource_stream::{EventStreamError, Eventsource}; use futures::StreamExt; let request = HttpRequest::builder() @@ -2258,11 +2370,7 @@ impl OpenSecretClient { }); } - let stream = response - .into_body() - .map(|result| result.map_err(std::io::Error::other)); - - let event_stream = stream.eventsource().filter_map(move |event| { + let event_stream = response.into_body().eventsource().filter_map(move |event| { async move { match event { Ok(event) => { @@ -2273,16 +2381,15 @@ impl OpenSecretClient { match serde_json::from_str::(&event.data) { Ok(chunk) => Some(Ok(chunk)), - Err(error) => Some(Err(Error::Api { - status: 0, - message: format!("Failed to parse chunk: {error}"), - })), + Err(error) => Some(Err(Error::InvalidResponse(format!( + "Failed to parse authenticated completion chunk: {error}" + )))), } } - Err(error) => Some(Err(Error::Api { - status: 0, - message: format!("SSE error: {error}"), - })), + Err(EventStreamError::Transport(error)) => Some(Err(error)), + Err(error) => Some(Err(Error::InvalidResponse(format!( + "Failed to parse authenticated completion SSE: {error}" + )))), } } }); @@ -2309,139 +2416,83 @@ impl OpenSecretClient { endpoint: String, input: &str, ) -> Result> + Send>>> { - use eventsource_stream::Eventsource; + use eventsource_stream::{EventStreamError, Eventsource}; use futures::StreamExt; + let expected_auth_epoch = self.session_manager.get_credential_snapshot()?.auth_epoch; let request = AgentChatRequest { input: input.to_string(), }; - - let (response, session_key) = self - .retry_encrypted_stream_call( + let (session, active_auth_epoch) = + self.ensure_v2_user_session(&expected_auth_epoch).await?; + let credentials = self.v2_credentials_for_epoch(&active_auth_epoch)?; + if !credentials + .user_session + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, &session)) + { + return Err(Error::Session( + "Credentials changed before the Agent request was sent".to_string(), + )); + } + let response = self + .v2_send_on_session( + &session, &endpoint, "POST", - Some(request), - AuthHeaderMode::Jwt, - true, + Some(serde_json::to_vec(&request)?), + vec![HeaderField::new( + header::ACCEPT.as_str(), + b"text/event-stream".to_vec(), + )], + V2SendOptions::bound(ResponseMode::Stream), ) .await?; + let status = response.status(); + if !status.is_success() { + if matches!(status.as_u16(), 401 | 403) { + self.transport_v2.clear_user_session_if(&session)?; + } + return Err(Self::v2_api_error_from_response(response).await); + } - let stream = response - .bytes_stream() - .map(|result| result.map_err(std::io::Error::other)); - - let event_stream = stream.eventsource().filter_map(move |event| { - let session_key = session_key; - async move { + let event_stream = response + .into_body() + .eventsource() + .filter_map(move |event| async move { match event { + Ok(event) if event.data == "[DONE]" => None, Ok(event) => { - if event.data == "[DONE]" { - return None; - } - - // Skip non-base64 events (heartbeats, retries, etc.) - let encrypted_bytes = match BASE64.decode(&event.data) { - Ok(bytes) => bytes, - Err(_) => return None, + let parsed = match event.event.as_str() { + "agent.message" => { + serde_json::from_str::(&event.data) + .map(AgentSseEvent::Message) + } + "agent.reaction" => { + serde_json::from_str::(&event.data) + .map(AgentSseEvent::Reaction) + } + "agent.typing" => serde_json::from_str::(&event.data) + .map(AgentSseEvent::Typing), + "agent.done" => serde_json::from_str::(&event.data) + .map(AgentSseEvent::Done), + "agent.error" => serde_json::from_str::(&event.data) + .map(AgentSseEvent::Error), + _ => return None, }; - match crypto::decrypt_data(&session_key, &encrypted_bytes) { - Ok(decrypted) => match String::from_utf8(decrypted) { - Ok(json_str) => { - let event_type = event.event.as_str(); - match event_type { - "agent.message" => { - match serde_json::from_str::( - &json_str, - ) { - Ok(msg) => Some(Ok(AgentSseEvent::Message(msg))), - Err(e) => Some(Err(Error::Api { - status: 0, - message: format!( - "Failed to parse agent message: {}", - e - ), - })), - } - } - "agent.reaction" => { - match serde_json::from_str::( - &json_str, - ) { - Ok(reaction) => { - Some(Ok(AgentSseEvent::Reaction(reaction))) - } - Err(e) => Some(Err(Error::Api { - status: 0, - message: format!( - "Failed to parse agent reaction: {}", - e - ), - })), - } - } - "agent.typing" => { - match serde_json::from_str::( - &json_str, - ) { - Ok(typing) => { - Some(Ok(AgentSseEvent::Typing(typing))) - } - Err(e) => Some(Err(Error::Api { - status: 0, - message: format!( - "Failed to parse agent typing: {}", - e - ), - })), - } - } - "agent.done" => { - match serde_json::from_str::(&json_str) - { - Ok(done) => Some(Ok(AgentSseEvent::Done(done))), - Err(e) => Some(Err(Error::Api { - status: 0, - message: format!( - "Failed to parse agent done: {}", - e - ), - })), - } - } - "agent.error" => { - match serde_json::from_str::(&json_str) - { - Ok(err) => Some(Ok(AgentSseEvent::Error(err))), - Err(e) => Some(Err(Error::Api { - status: 0, - message: format!( - "Failed to parse agent error: {}", - e - ), - })), - } - } - _ => None, - } - } - Err(e) => Some(Err(Error::Api { - status: 0, - message: format!("Invalid UTF-8 in decrypted data: {}", e), - })), - }, - Err(e) => Some(Err(Error::Decryption(format!( - "Failed to decrypt agent event: {}", - e - )))), - } + Some(parsed.map_err(|error| { + Error::InvalidResponse(format!( + "Failed to parse {} agent event: {error}", + event.event + )) + })) } - Err(e) => Some(Err(Error::Api { - status: 0, - message: format!("SSE error: {}", e), - })), + Err(EventStreamError::Transport(error)) => Some(Err(error)), + Err(error) => Some(Err(Error::InvalidResponse(format!( + "Failed to parse authenticated agent SSE: {error}" + )))), } - } - }); + }); Ok(Box::pin(event_stream)) } @@ -2617,3474 +2668,631 @@ impl OpenSecretClient { } } +const fn v2_user_binding_is_fresh_at(expires_at_unix_seconds: u64, now_unix_seconds: u64) -> bool { + expires_at_unix_seconds > now_unix_seconds.saturating_add(V2_USER_AUTH_RENEWAL_SKEW_SECONDS) +} + +fn should_use_anonymous_models_session(path: &str, has_user_credentials: bool) -> bool { + path == "/v1/models" && !has_user_credentials +} + +fn decode_v2_user_binding_response( + response_body: &[u8], +) -> Result<(LoginResponse, U, ValidatedUserTokenPair)> { + let login: LoginResponse = serde_json::from_slice(response_body)?; + let validated = validate_v2_user_token_pair(&login.access_token, &login.refresh_token)?; + if validated.principal != login.id.to_string() { + return Err(Error::Authentication( + "Transport v2 authentication response principal did not match its descriptors" + .to_string(), + )); + } + let value = serde_json::from_slice(response_body)?; + Ok((login, value, validated)) +} + #[cfg(test)] mod tests { use super::*; - use crate::PushNotificationKeyPair; - use futures::StreamExt; - use serde_json::json; - use std::{ - collections::HashMap, - sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, Mutex as StdMutex, - }, - time::Duration, - }; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; use wiremock::{ - matchers::{header, method, path, query_param}, - Match, Mock, MockServer, Request, Respond, ResponseTemplate, + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, }; - const DEVELOPMENT_PCR0: &str = - "62c0407056217a4c10764ed9045694c29fa93255d3cc04c2f989cdd9a1f8050c8b169714c71f1118ebce2fcc9951d1a9"; + const USER_ACCESS_AUDIENCE: &str = + "urn:opensecret:internal:transport-v2:user:access-descriptor"; + const USER_RESUMPTION_AUDIENCE: &str = "urn:opensecret:internal:transport-v2:user:resumption"; + + fn descriptor(audience: &str, kind: &str, subject: &str) -> String { + let claims = serde_json::json!({ + "iss": "urn:opensecret:transport-v2", + "aud": audience, + "tv": 2, + "tk": kind, + "pk": "user", + "sub": subject, + "exp": 2_000_000_000_u64, + }); + format!( + "e30.{}.signature", + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()) + ) + } - #[test] - fn response_recovery_classifier_is_versioned_and_fail_closed() { - fn headers(contract: Option<&str>, code: Option<&str>) -> HeaderMap { - let mut headers = HeaderMap::new(); - if let Some(contract) = contract { - headers.insert( - ERROR_CONTRACT_HEADER, - HeaderValue::from_str(contract).unwrap(), - ); - } - if let Some(code) = code { - headers.insert(ERROR_CODE_HEADER, HeaderValue::from_str(code).unwrap()); - } - headers - } + fn v2_session(marker: u8, expires_at_unix_seconds: u64) -> Arc { + Arc::new( + V2Session::from_master_for_test( + Uuid::from_bytes([marker; 16]), + [marker; 32], + expires_at_unix_seconds, + ) + .unwrap(), + ) + } - for (status, expected) in [ - ( - reqwest::StatusCode::BAD_REQUEST, - Some(RecoveryAction::Reattest), - ), - ( - reqwest::StatusCode::UNAUTHORIZED, - Some(RecoveryAction::RefreshAccessToken), - ), - (reqwest::StatusCode::UNPROCESSABLE_ENTITY, None), - ] { - assert_eq!( - classify_response_recovery(status, &HeaderMap::new()), - expected - ); - } + fn install_anonymous(client: &OpenSecretClient, session: Arc) { + client + .transport_v2 + .set_anonymous_session_for_test(session) + .unwrap(); + } - assert_eq!( - classify_response_recovery( - reqwest::StatusCode::BAD_REQUEST, - &headers(Some("1"), Some("session_not_found")), - ), - Some(RecoveryAction::Reattest) - ); - assert_eq!( - classify_response_recovery( - reqwest::StatusCode::UNAUTHORIZED, - &headers(Some("1"), Some("access_token_expired")), - ), - Some(RecoveryAction::RefreshAccessToken) - ); + fn logical_response(status: u16, body: impl Into>) -> V2HttpResponse { + let body = Bytes::from(body.into()); + let stream: OpenSecretResponseBody = + Box::pin(futures::stream::once(async move { Ok(body) })); + let mut response = HttpResponse::new(stream); + *response.status_mut() = http::StatusCode::from_u16(status).unwrap(); + response + } - for (status, contract, code) in [ - (reqwest::StatusCode::BAD_REQUEST, Some("1"), None), - (reqwest::StatusCode::BAD_REQUEST, Some("1"), Some("unknown")), - ( - reqwest::StatusCode::BAD_REQUEST, - Some("1"), - Some("access_token_expired"), - ), - ( - reqwest::StatusCode::UNAUTHORIZED, - Some("1"), - Some("session_not_found"), + fn login_response_body(user_id: Uuid) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "id": user_id, + "email": "native@example.test", + "access_token": descriptor( + USER_ACCESS_AUDIENCE, + "access_descriptor", + &user_id.to_string(), ), - ( - reqwest::StatusCode::BAD_REQUEST, - Some("2"), - Some("session_not_found"), + "refresh_token": descriptor( + USER_RESUMPTION_AUDIENCE, + "resumption", + &user_id.to_string(), ), - ] { - assert_eq!( - classify_response_recovery(status, &headers(contract, code)), - None - ); - } + })) + .unwrap() + } - // A code without the version marker is still a legacy response. - assert_eq!( - classify_response_recovery( - reqwest::StatusCode::BAD_REQUEST, - &headers(None, Some("unknown")), - ), - Some(RecoveryAction::Reattest) - ); + fn handoff_grant() -> NativeOAuthHandoffGrant { + NativeOAuthHandoffGrant::new("header.payload.signature").unwrap() + } - let mut duplicate_contract = headers(Some("1"), Some("session_not_found")); - duplicate_contract.append(ERROR_CONTRACT_HEADER, HeaderValue::from_static("1")); - assert_eq!( - classify_response_recovery(reqwest::StatusCode::BAD_REQUEST, &duplicate_contract), - None - ); + #[test] + fn native_oauth_handoff_grant_is_canonical_bounded_and_redacted() { + let grant = handoff_grant(); + assert_eq!(format!("{grant:?}"), "NativeOAuthHandoffGrant([REDACTED])"); + + let boundary = format!("{}.b.c", "a".repeat(4_092)); + assert_eq!(boundary.len(), 4_096); + assert!(NativeOAuthHandoffGrant::new(boundary).is_ok()); + + for invalid in [ + "", + "header.payload", + "header.payload.signature.extra", + "header..signature", + "header.payload.signature=", + "header.payload.sign ature", + ] { + assert!(NativeOAuthHandoffGrant::new(invalid).is_err(), "{invalid}"); + } + let too_large = format!("{}.b.c", "a".repeat(4_093)); + assert_eq!(too_large.len(), 4_097); + assert!(NativeOAuthHandoffGrant::new(too_large).is_err()); + } - let mut duplicate_code = headers(Some("1"), Some("session_not_found")); - duplicate_code.append( - ERROR_CODE_HEADER, - HeaderValue::from_static("session_not_found"), - ); + #[test] + fn native_oauth_handoff_body_has_only_the_frozen_fields() { + let attempt = Uuid::from_bytes([0x44; 16]); + let request = NativeOAuthHandoffRedeemRequest { + grant: "header.payload.signature", + native_attempt_id: attempt, + }; assert_eq!( - classify_response_recovery(reqwest::StatusCode::BAD_REQUEST, &duplicate_code), - None + serde_json::to_value(request).unwrap(), + serde_json::json!({ + "grant": "header.payload.signature", + "native_attempt_id": attempt, + }) ); } - fn synthetic_verified_attestation(pcr0: &str) -> AttestationDocument { - AttestationDocument { - module_id: "test-module".to_string(), - timestamp: 1, - digest: "SHA384".to_string(), - pcrs: HashMap::from([(0, hex::decode(pcr0).expect("valid test PCR0"))]), - certificate: Vec::new(), - cabundle: Vec::new(), - public_key: Some(vec![7; 32]), - user_data: None, - nonce: Some(b"test-nonce".to_vec()), - } + #[tokio::test] + async fn native_oauth_preparation_reuses_the_exact_anonymous_session() { + let client = OpenSecretClient::new("http://127.0.0.1:9").unwrap(); + let session = v2_session(0x31, u64::MAX); + install_anonymous(&client, Arc::clone(&session)); + + let prepared = client.prepare_native_oauth_session().await.unwrap(); + assert_eq!(prepared.session_id, session.session_id()); + assert!(Arc::ptr_eq( + &client.transport_v2.anonymous_session().unwrap().unwrap(), + &session, + )); } - struct MissingHeaderMatcher(&'static str); + #[tokio::test] + async fn native_oauth_preparation_rejects_existing_authority_before_network_io() { + let api_key_client = + OpenSecretClient::new_with_api_key("http://127.0.0.1:9", "test-api-key".to_string()) + .unwrap(); + assert!(matches!( + api_key_client.prepare_native_oauth_session().await, + Err(Error::Authentication(_)) + )); - impl Match for MissingHeaderMatcher { - fn matches(&self, request: &Request) -> bool { - !request.headers.contains_key(self.0) - } + let user_client = OpenSecretClient::new("http://127.0.0.1:9").unwrap(); + user_client + .set_tokens( + descriptor(USER_ACCESS_AUDIENCE, "access_descriptor", "user-1"), + Some(descriptor(USER_RESUMPTION_AUDIENCE, "resumption", "user-1")), + ) + .unwrap(); + assert!(matches!( + user_client.prepare_native_oauth_session().await, + Err(Error::Authentication(_)) + )); } - struct PathPrefixMatcher(&'static str); - - impl Match for PathPrefixMatcher { - fn matches(&self, request: &Request) -> bool { - request.url.path().starts_with(self.0) - } + #[tokio::test] + async fn native_oauth_redeem_rejects_wrong_session_without_sending() { + let client = OpenSecretClient::new("http://127.0.0.1:9").unwrap(); + let session = v2_session(0x32, u64::MAX); + install_anonymous(&client, Arc::clone(&session)); + + let result = client + .redeem_native_oauth_handoff( + Uuid::from_bytes([0x33; 16]), + Uuid::new_v4(), + handoff_grant(), + ) + .await; + assert!(matches!(result, Err(Error::Authentication(_)))); + assert!(Arc::ptr_eq( + &client.transport_v2.anonymous_session().unwrap().unwrap(), + &session, + )); } - #[derive(Debug)] - struct EncryptedJsonBodyMatcher { - session_key: [u8; 32], - expected: serde_json::Value, + #[tokio::test] + async fn native_oauth_redeem_rejects_expired_session_without_sending() { + let client = OpenSecretClient::new("http://127.0.0.1:9").unwrap(); + let session = v2_session(0x33, 0); + let session_id = session.session_id(); + install_anonymous(&client, session); + + let result = client + .redeem_native_oauth_handoff(session_id, Uuid::new_v4(), handoff_grant()) + .await; + assert!(matches!(result, Err(Error::Authentication(_)))); + assert!(client.transport_v2.anonymous_session().unwrap().is_none()); } - impl Match for EncryptedJsonBodyMatcher { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(request.body.as_ref()) else { - return false; - }; - let Ok(encrypted) = BASE64.decode(body.encrypted.as_bytes()) else { - return false; - }; - let Ok(plaintext) = crypto::decrypt_data(&self.session_key, &encrypted) else { - return false; - }; - let Ok(actual) = serde_json::from_slice::(&plaintext) else { - return false; - }; + #[tokio::test] + async fn native_oauth_redeem_sends_once_and_retires_ambiguous_session() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v2/request")) + .respond_with(ResponseTemplate::new(503)) + .expect(1) + .mount(&server) + .await; + let client = OpenSecretClient::new(server.uri()).unwrap(); + let session = v2_session(0x34, u64::MAX); + let session_id = session.session_id(); + install_anonymous(&client, session); - actual == self.expected - } + let result = client + .redeem_native_oauth_handoff(session_id, Uuid::new_v4(), handoff_grant()) + .await; + assert!(matches!(result, Err(Error::Api { status: 503, .. }))); + assert!(client.transport_v2.anonymous_session().unwrap().is_none()); } - #[derive(Debug)] - struct EncryptedBytesBodyMatcher { - session_key: [u8; 32], - expected: Bytes, + #[tokio::test] + async fn native_oauth_success_moves_the_exact_session_to_user_authority() { + let client = OpenSecretClient::new("http://127.0.0.1:9").unwrap(); + let session = v2_session(0x35, u64::MAX); + install_anonymous(&client, Arc::clone(&session)); + let expected_epoch = client + .session_manager + .get_credential_snapshot() + .unwrap() + .auth_epoch; + let user_id = Uuid::from_bytes([0x36; 16]); + + let login: LoginResponse = client + .finish_v2_user_binding_transition( + &session, + &expected_epoch, + logical_response(200, login_response_body(user_id)), + ) + .await + .unwrap(); + assert_eq!(login.id, user_id); + assert!(client.transport_v2.anonymous_session().unwrap().is_none()); + assert!(Arc::ptr_eq( + &client.session_manager.get_user_session().unwrap().unwrap(), + &session, + )); + assert_eq!( + client + .session_manager + .get_credential_snapshot() + .unwrap() + .auth_epoch + .principal + .as_deref(), + Some(user_id.to_string().as_str()), + ); } - impl Match for EncryptedBytesBodyMatcher { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(request.body.as_ref()) else { - return false; - }; - let Ok(encrypted) = BASE64.decode(body.encrypted.as_bytes()) else { - return false; - }; - let Ok(actual) = crypto::decrypt_data(&self.session_key, &encrypted) else { - return false; - }; + #[tokio::test] + async fn malformed_native_oauth_success_retires_anonymous_without_installing_auth() { + let client = OpenSecretClient::new("http://127.0.0.1:9").unwrap(); + let session = v2_session(0x37, u64::MAX); + install_anonymous(&client, Arc::clone(&session)); + let expected_epoch = client + .session_manager + .get_credential_snapshot() + .unwrap() + .auth_epoch; - actual == self.expected - } + let result: Result = client + .finish_v2_user_binding_transition( + &session, + &expected_epoch, + logical_response(200, br#"{}"#.to_vec()), + ) + .await; + assert!(result.is_err()); + assert!(client.transport_v2.anonymous_session().unwrap().is_none()); + assert!(client.get_tokens().unwrap().is_none()); + assert!(client.session_manager.get_user_session().unwrap().is_none()); } - struct AttestationResponder { - server_public_key: [u8; 32], + #[tokio::test] + async fn rejected_native_oauth_handoff_preserves_the_exact_anonymous_session() { + let client = OpenSecretClient::new("http://127.0.0.1:9").unwrap(); + let session = v2_session(0x3b, u64::MAX); + install_anonymous(&client, Arc::clone(&session)); + let expected_epoch = client + .session_manager + .get_credential_snapshot() + .unwrap() + .auth_epoch; + + let result: Result = client + .finish_v2_user_binding_transition( + &session, + &expected_epoch, + logical_response(401, br#"{"error":"invalid grant"}"#.to_vec()), + ) + .await; + assert!(matches!(result, Err(Error::Api { status: 401, .. }))); + assert!(Arc::ptr_eq( + &client.transport_v2.anonymous_session().unwrap().unwrap(), + &session, + )); + assert!(client.get_tokens().unwrap().is_none()); } - impl Respond for AttestationResponder { - fn respond(&self, request: &Request) -> ResponseTemplate { - let nonce = request.url.path().rsplit('/').next().unwrap_or_default(); - let attestation_document = - build_mock_attestation_document(nonce, &self.server_public_key); + #[tokio::test] + async fn stale_native_oauth_success_cannot_overwrite_newer_credentials() { + let client = OpenSecretClient::new("http://127.0.0.1:9").unwrap(); + let session = v2_session(0x38, u64::MAX); + install_anonymous(&client, Arc::clone(&session)); + let expected_epoch = client + .session_manager + .get_credential_snapshot() + .unwrap() + .auth_epoch; + let newer_user = Uuid::from_bytes([0x39; 16]); + client + .set_tokens( + descriptor( + USER_ACCESS_AUDIENCE, + "access_descriptor", + &newer_user.to_string(), + ), + Some(descriptor( + USER_RESUMPTION_AUDIENCE, + "resumption", + &newer_user.to_string(), + )), + ) + .unwrap(); + let stale_user = Uuid::from_bytes([0x3a; 16]); - ResponseTemplate::new(200) - .set_body_json(json!({ "attestation_document": attestation_document })) - } + let result: Result = client + .finish_v2_user_binding_transition( + &session, + &expected_epoch, + logical_response(200, login_response_body(stale_user)), + ) + .await; + assert!(matches!(result, Err(Error::Session(_)))); + assert_eq!( + client + .session_manager + .get_credential_snapshot() + .unwrap() + .auth_epoch + .principal + .as_deref(), + Some(newer_user.to_string().as_str()), + ); } - struct KeyExchangeResponder { - server_secret_key: [u8; 32], - session_key: [u8; 32], - session_id: String, + #[test] + fn cache_namespace_root_is_canonical_cloneable_and_redacted() { + let root = TransportV2CacheNamespaceRoot::from_bytes([0x42; 32]); + let clone = root.clone(); + let encoded = root.to_base64(); + assert_eq!( + TransportV2CacheNamespaceRoot::from_base64(&encoded).unwrap(), + root + ); + assert_eq!(clone, root); + assert_eq!( + format!("{root:?}"), + "TransportV2CacheNamespaceRoot([REDACTED])" + ); + assert!(TransportV2CacheNamespaceRoot::from_base64(encoded.trim_end_matches('=')).is_err()); + assert!(TransportV2CacheNamespaceRoot::from_base64(&BASE64.encode([0_u8; 31])).is_err()); } - impl Respond for KeyExchangeResponder { - fn respond(&self, request: &Request) -> ResponseTemplate { - let body: KeyExchangeRequest = serde_json::from_slice(request.body.as_ref()).unwrap(); - let client_public_bytes = BASE64.decode(body.client_public_key.as_bytes()).unwrap(); - let client_public_key = x25519_dalek::PublicKey::from( - <[u8; 32]>::try_from(client_public_bytes.as_slice()).unwrap(), + #[test] + fn inference_response_mode_reads_only_one_boolean_stream_selector() { + assert_eq!( + inference_response_mode("/v1/chat/completions", br#"{"stream":true,"model":"x"}"#) + .unwrap(), + ResponseMode::Stream + ); + for body in [ + br#"{"model":"x"}"#.as_slice(), + br#"{"stream":false}"#.as_slice(), + br#"{"stream":true,"stream":false}"#.as_slice(), + br#"{"stream":"true"}"#.as_slice(), + b"not-json".as_slice(), + ] { + assert_eq!( + inference_response_mode("/v1/chat/completions", body).unwrap(), + ResponseMode::Unary ); - let server_secret = x25519_dalek::StaticSecret::from(self.server_secret_key); - let shared_secret = - crypto::perform_static_key_exchange(&server_secret, &client_public_key); - let encrypted_session_key = BASE64 - .encode(crypto::encrypt_data(shared_secret.as_bytes(), &self.session_key).unwrap()); - - ResponseTemplate::new(200).set_body_json(json!({ - "encrypted_session_key": encrypted_session_key, - "session_id": self.session_id, - })) } + assert_eq!( + inference_response_mode("/v1/embeddings", br#"{"stream":true}"#).unwrap(), + ResponseMode::Unary + ); } - #[derive(Clone)] - struct PerNonceAttestationResponder { - server_secrets: Arc>>, - next_key: Arc, - } - - impl Respond for PerNonceAttestationResponder { - fn respond(&self, request: &Request) -> ResponseTemplate { - let nonce = request.url.path().rsplit('/').next().unwrap_or_default(); - let key_byte = self.next_key.fetch_add(1, Ordering::SeqCst) as u8 + 1; - let server_secret_key = [key_byte; 32]; - let server_public_key = - x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(server_secret_key)); - self.server_secrets - .lock() - .unwrap() - .insert(nonce.to_string(), server_secret_key); - - ResponseTemplate::new(200).set_body_json(json!({ - "attestation_document": build_mock_attestation_document( - nonce, - server_public_key.as_bytes(), - ) - })) - } - } - - #[derive(Clone)] - struct PerNonceKeyExchangeResponder { - server_secrets: Arc>>, - session_key: [u8; 32], - } - - impl Respond for PerNonceKeyExchangeResponder { - fn respond(&self, request: &Request) -> ResponseTemplate { - let body: KeyExchangeRequest = serde_json::from_slice(request.body.as_ref()).unwrap(); - let server_secret_key = *self - .server_secrets - .lock() - .unwrap() - .get(&body.nonce) - .expect("key exchange nonce must match an attestation"); - let client_public_bytes = BASE64.decode(body.client_public_key.as_bytes()).unwrap(); - let client_public_key = x25519_dalek::PublicKey::from( - <[u8; 32]>::try_from(client_public_bytes.as_slice()).unwrap(), + #[test] + fn inference_header_filter_removes_every_credential_and_framing_alias() { + let mut input = HttpHeaderMap::new(); + for name in [ + "authorization", + "cookie", + "x-session-id", + "content-length", + "content-type", + "content-encoding", + "accept-encoding", + "content-md5", + "digest", + "x-api-key", + "api-key", + "x-openai-api-key", + "x-tinfoil-api-key", + "x-goog-api-key", + "x-anthropic-api-key", + "openai-organization", + "openai-project", + ] { + input.insert( + http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + http::HeaderValue::from_static("secret"), ); - let server_secret = x25519_dalek::StaticSecret::from(server_secret_key); - let shared_secret = - crypto::perform_static_key_exchange(&server_secret, &client_public_key); - let encrypted_session_key = BASE64 - .encode(crypto::encrypt_data(shared_secret.as_bytes(), &self.session_key).unwrap()); - let session_id = Uuid::from_u128(server_secret_key[0] as u128).to_string(); - - // Keep both key exchanges in flight long enough for both distinct - // attestation results to be processed by the client. - ResponseTemplate::new(200) - .set_delay(Duration::from_millis(100)) - .set_body_json(json!({ - "encrypted_session_key": encrypted_session_key, - "session_id": session_id, - })) - } - } - - fn build_mock_attestation_document(nonce: &str, server_public_key: &[u8; 32]) -> String { - let payload = CborValue::Map(vec![ - ( - CborValue::Text("public_key".to_string()), - CborValue::Bytes(server_public_key.to_vec()), - ), - ( - CborValue::Text("nonce".to_string()), - CborValue::Bytes(nonce.as_bytes().to_vec()), - ), - ]); - - let payload = cbor::to_vec(&payload).unwrap(); - let cose_sign1 = CborValue::Array(vec![ - CborValue::Bytes(vec![]), - CborValue::Map(Vec::new()), - CborValue::Bytes(payload), - CborValue::Bytes(vec![]), - ]); - - BASE64.encode(cbor::to_vec(&cose_sign1).unwrap()) - } - - fn encrypted_response(session_key: &[u8; 32], payload: &T) -> serde_json::Value { - let plaintext = serde_json::to_vec(payload).unwrap(); - let encrypted = crypto::encrypt_data(session_key, &plaintext).unwrap(); - json!({ "encrypted": BASE64.encode(encrypted) }) - } - - fn encrypted_response_bytes(session_key: &[u8; 32], payload: &[u8]) -> serde_json::Value { - let encrypted = crypto::encrypt_data(session_key, payload).unwrap(); - json!({ "encrypted": BASE64.encode(encrypted) }) - } - - fn v1_error_response( - status: u16, - code: Option<&'static str>, - body: &'static str, - ) -> ResponseTemplate { - let response = ResponseTemplate::new(status) - .insert_header(ERROR_CONTRACT_HEADER, "1") - .set_body_string(body); - match code { - Some(code) => response.insert_header(ERROR_CODE_HEADER, code), - None => response, - } - } - - fn encrypted_sse_data(session_key: &[u8; 32], payload: &T) -> String { - let plaintext = serde_json::to_vec(payload).unwrap(); - let encrypted = crypto::encrypt_data(session_key, &plaintext).unwrap(); - format!("data: {}\n\n", BASE64.encode(encrypted)) - } - - fn encrypted_sse_bytes(session_key: &[u8; 32], payload: &[u8]) -> String { - let encrypted = crypto::encrypt_data(session_key, payload).unwrap(); - BASE64.encode(encrypted) - } - - fn decrypt_request_body( - request: &Request, - session_key: &[u8; 32], - ) -> T { - let body: EncryptedRequest = serde_json::from_slice(request.body.as_ref()).unwrap(); - let encrypted = BASE64.decode(body.encrypted.as_bytes()).unwrap(); - let plaintext = crypto::decrypt_data(session_key, &encrypted).unwrap(); - serde_json::from_slice(&plaintext).unwrap() - } - - struct RegisterPushDeviceResponder { - session_key: [u8; 32], - expected_request: RegisterPushDeviceRequest, - response_device: PushDevice, - } - - impl Respond for RegisterPushDeviceResponder { - fn respond(&self, request: &Request) -> ResponseTemplate { - let body: RegisterPushDeviceRequest = decrypt_request_body(request, &self.session_key); - assert_eq!(body, self.expected_request); - - ResponseTemplate::new(200) - .set_body_json(encrypted_response(&self.session_key, &self.response_device)) } - } + input.insert("x-provider-beta", http::HeaderValue::from_static("safe")); + input.insert("connection", http::HeaderValue::from_static("x-nominated")); + input.insert("x-nominated", http::HeaderValue::from_static("remove")); - struct LogoutWithPushDeviceResponder { - session_key: [u8; 32], - expected_push_device_id: Uuid, + let output = sanitize_inference_request_headers(&input); + assert_eq!(output.len(), 1); + assert_eq!(output.get("x-provider-beta").unwrap(), "safe"); } - impl Respond for LogoutWithPushDeviceResponder { - fn respond(&self, request: &Request) -> ResponseTemplate { - let body: LogoutRequest = decrypt_request_body(request, &self.session_key); - assert_eq!(body.push_device_id, Some(self.expected_push_device_id)); - - ResponseTemplate::new(200).set_body_json(encrypted_response( - &self.session_key, - &json!({ "ok": true }), - )) + #[test] + fn explicit_allowed_inference_surface_is_unchanged() { + for (method, path) in [ + (http::Method::GET, "/v1/models"), + (http::Method::GET, "/v1/models/catalog"), + (http::Method::POST, "/v1/chat/completions"), + (http::Method::POST, "/v1/embeddings"), + (http::Method::POST, "/v1/audio/speech"), + (http::Method::POST, "/v1/audio/transcriptions"), + ] { + assert!(is_allowed_inference_endpoint(&method, path)); } + assert!(!is_allowed_inference_endpoint( + &http::Method::POST, + "/v1/responses" + )); + assert!(!is_allowed_inference_endpoint( + &http::Method::GET, + "/v1/chat/completions" + )); } #[test] - fn test_build_conversations_endpoint_includes_filters() { - let endpoint = build_conversations_endpoint(Some(&ConversationsListParams { - limit: Some(25), - after: Some(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()), - order: Some("asc".to_string()), - project_id: Some(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap()), - unassigned_project: Some(false), - pinned: Some(false), - })); - - assert_eq!( - endpoint, - "/v1/conversations?limit=25&after=550e8400%2De29b%2D41d4%2Da716%2D446655440000&order=asc&project_id=550e8400%2De29b%2D41d4%2Da716%2D446655440001&unassigned_project=false&pinned=false" - ); + fn models_are_anonymous_only_when_no_authority_is_available() { + assert!(should_use_anonymous_models_session("/v1/models", false)); + assert!(!should_use_anonymous_models_session("/v1/models", true)); + assert!(!should_use_anonymous_models_session( + "/v1/models/catalog", + false + )); } #[test] - fn test_build_conversations_endpoint_supports_unassigned_project_filter() { - let endpoint = build_conversations_endpoint(Some(&ConversationsListParams { - limit: None, - after: None, - order: None, - project_id: None, - unassigned_project: Some(true), - pinned: None, - })); - - assert_eq!(endpoint, "/v1/conversations?unassigned_project=true"); + fn user_binding_renews_before_its_authenticated_deadline() { + assert!(v2_user_binding_is_fresh_at(1_031, 1_000)); + assert!(!v2_user_binding_is_fresh_at(1_030, 1_000)); + assert!(!v2_user_binding_is_fresh_at(1_029, 1_000)); + assert!(!v2_user_binding_is_fresh_at(u64::MAX, u64::MAX)); } #[test] - fn test_build_conversation_projects_endpoint_includes_pagination() { - let endpoint = build_conversation_projects_endpoint(Some(&ConversationProjectListParams { - limit: Some(10), - after: Some(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()), - order: Some("desc".to_string()), - })); - - assert_eq!( - endpoint, - "/v1/conversation-projects?limit=10&after=550e8400%2De29b%2D41d4%2Da716%2D446655440000&order=desc" - ); - } - - #[tokio::test] - async fn test_update_conversation_rejects_empty_request_locally() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - - let error = client - .update_conversation(Uuid::new_v4(), ConversationUpdateRequest::default()) - .await - .unwrap_err(); - - assert!( - matches!(error, Error::Configuration(message) if message.contains("at least one field")) - ); - } - - #[tokio::test] - async fn test_update_conversation_project_rejects_empty_request_locally() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - - let error = client - .update_conversation_project( - Uuid::new_v4(), - ConversationProjectUpdateRequest::default(), - ) - .await - .unwrap_err(); - - assert!( - matches!(error, Error::Configuration(message) if message.contains("at least one field")) - ); - } - - #[tokio::test] - async fn test_client_creation() { - let client = OpenSecretClient::new("http://localhost:3000").unwrap(); - assert_eq!(client.base_url, "http://localhost:3000"); - assert!(client.use_mock_attestation); - } - - #[tokio::test] - async fn cross_environment_pcr0_fails_before_key_exchange() { - let mock_server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/key_exchange")) - .respond_with(ResponseTemplate::new(500)) - .expect(0) - .mount(&mock_server) - .await; - - let production_policy = - Pcr0TrustPolicy::official_for(Pcr0Environment::Production).without_remote_history(); - let client = - OpenSecretClient::new_with_pcr0_trust_policy(mock_server.uri(), production_policy) - .unwrap(); - let document = synthetic_verified_attestation(DEVELOPMENT_PCR0); - let nonce = Uuid::new_v4().to_string(); - - let error = client - .establish_session_from_verified_attestation(&nonce, document) - .await - .unwrap_err(); - - assert!(matches!(error, Error::AttestationVerificationFailed(_))); - assert!(client.get_session_id().unwrap().is_none()); - mock_server.verify().await; - } - - #[tokio::test] - async fn development_environment_accepts_development_pcr0_before_key_exchange() { - let mock_server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/key_exchange")) - .respond_with(ResponseTemplate::new(500)) - .expect(1) - .mount(&mock_server) - .await; - - let client = OpenSecretClient::new_with_pcr0_environment( - mock_server.uri(), - Pcr0Environment::Development, - ) + fn successful_binding_response_is_validated_before_session_install() { + let user_id = Uuid::parse_str("123e4567-e89b-12d3-a456-426614174000").unwrap(); + let access = descriptor( + USER_ACCESS_AUDIENCE, + "access_descriptor", + &user_id.to_string(), + ); + let refresh = descriptor(USER_RESUMPTION_AUDIENCE, "resumption", &user_id.to_string()); + let body = serde_json::json!({ + "id": user_id, + "access_token": access, + "refresh_token": refresh, + }); + let bytes = serde_json::to_vec(&body).unwrap(); + let (login, value, validated): (LoginResponse, LoginResponse, ValidatedUserTokenPair) = + decode_v2_user_binding_response(&bytes).unwrap(); + assert_eq!(login.id, user_id); + assert_eq!(value.id, user_id); + assert_eq!(validated.principal, user_id.to_string()); + + let invalid = serde_json::to_vec(&serde_json::json!({ + "id": user_id, + "access_token": "legacy-access", + "refresh_token": "legacy-refresh", + })) .unwrap(); - let document = synthetic_verified_attestation(DEVELOPMENT_PCR0); - let nonce = Uuid::new_v4().to_string(); - - let error = client - .establish_session_from_verified_attestation(&nonce, document) - .await - .unwrap_err(); - - assert!(matches!(error, Error::Api { status: 500, .. })); - assert!(client.get_session_id().unwrap().is_none()); - mock_server.verify().await; - } - - #[test] - fn mock_attestation_uses_the_parsed_host_not_url_substrings() { - for url in [ - "https://localhost.example.com", - "https://example.com/localhost", - "https://example.com/127.0.0.1", - ] { - let client = OpenSecretClient::new(url).unwrap(); - assert!(!client.use_mock_attestation, "unexpected mock URL: {url}"); - } - - assert!( - OpenSecretClient::new("http://localhost:3000") - .unwrap() - .use_mock_attestation - ); - assert!( - OpenSecretClient::new("http://127.0.0.1:3000") - .unwrap() - .use_mock_attestation - ); - assert!( - OpenSecretClient::new("http://[::1]:3000") - .unwrap() - .use_mock_attestation - ); - } - - #[test] - fn base_url_validation_rejects_malformed_or_ambiguous_urls() { - for url in [ - "not a URL", - "file:///tmp/opensecret", - "https://localhost@example.com", - "https://example.com?redirect=localhost", - "https://example.com/#localhost", - ] { - assert!( - OpenSecretClient::new(url).is_err(), - "unexpectedly accepted base URL: {url}" - ); - } - } - - #[test] - fn android_emulator_alias_is_not_a_desktop_mock_bypass() { - let client = OpenSecretClient::new("http://10.0.2.2:3000"); - if cfg!(target_os = "android") { - assert!(client.unwrap().use_mock_attestation); - } else { - assert!(client.is_err()); - assert!( - !OpenSecretClient::new("https://10.0.2.2:3000") - .unwrap() - .use_mock_attestation - ); - } + assert!(decode_v2_user_binding_response::(&invalid).is_err()); } #[test] - fn get_tokens_returns_one_coherent_pair_snapshot() { + fn legacy_credentials_require_a_fresh_v2_login() { let client = OpenSecretClient::new("http://localhost:3000").unwrap(); - client - .set_tokens("access".to_string(), Some("refresh".to_string())) - .unwrap(); - - let tokens = client.get_tokens().unwrap().unwrap(); - assert_eq!(tokens.access_token, "access"); - assert_eq!(tokens.refresh_token.as_deref(), Some("refresh")); - } - - #[tokio::test] - async fn concurrent_attestation_handshakes_keep_each_nonce_public_key() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let server_secrets = Arc::new(StdMutex::new(HashMap::new())); - let next_key = Arc::new(AtomicUsize::new(0)); - let session_key = [42u8; 32]; - - Mock::given(method("GET")) - .and(PathPrefixMatcher("/attestation/")) - .respond_with(PerNonceAttestationResponder { - server_secrets: Arc::clone(&server_secrets), - next_key, - }) - .expect(2) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/key_exchange")) - .respond_with(PerNonceKeyExchangeResponder { - server_secrets: Arc::clone(&server_secrets), - session_key, - }) - .expect(2) - .mount(&mock_server) - .await; - - let (first, second) = tokio::join!( - client.perform_attestation_handshake(), - client.perform_attestation_handshake() - ); - - first.unwrap(); - second.unwrap(); - assert_eq!(server_secrets.lock().unwrap().len(), 2); - assert!(client.get_session_id().unwrap().is_some()); - } - - #[tokio::test] - async fn test_register_push_device_uses_v1_push_endpoint() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [21u8; 32]; - let now = chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z") - .unwrap() - .with_timezone(&chrono::Utc); - - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager + assert!(client .set_tokens( - "access_token".to_string(), - Some("refresh_token".to_string()), + "legacy-access".to_string(), + Some("legacy-refresh".to_string()) ) - .unwrap(); - - let key_pair = PushNotificationKeyPair::generate(); - let request = RegisterPushDeviceRequest::new( - Uuid::new_v4(), - PushPlatform::Ios, - PushEnvironment::Prod, - "ai.trymaple.ios", - "opaque-token", - key_pair.public_key_spki_base64().unwrap(), - ) - .supports_encrypted_preview(true) - .supports_background_processing(true); - - let response_device = PushDevice { - id: Uuid::new_v4(), - object: "push.device".to_string(), - installation_id: request.installation_id, - platform: request.platform, - provider: request.provider, - environment: request.environment, - app_id: request.app_id.clone(), - key_algorithm: request.key_algorithm, - supports_encrypted_preview: request.supports_encrypted_preview, - supports_background_processing: request.supports_background_processing, - last_seen_at: now, - created_at: now, - updated_at: now, - }; - - Mock::given(method("POST")) - .and(path("/v1/push/devices")) - .and(header("authorization", "Bearer access_token")) - .and(header("x-session-id", session_id.to_string())) - .respond_with(RegisterPushDeviceResponder { - session_key, - expected_request: request.clone(), - response_device: response_device.clone(), - }) - .expect(1) - .mount(&mock_server) - .await; - - let response = client.register_push_device(request).await.unwrap(); - - assert_eq!(response, response_device); - } - - #[tokio::test] - async fn test_list_and_revoke_push_devices_use_v1_endpoints() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [22u8; 32]; - let now = chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z") - .unwrap() - .with_timezone(&chrono::Utc); - let device_id = Uuid::new_v4(); + .is_err()); + assert!(client.get_tokens().unwrap().is_none()); + let access = descriptor(USER_ACCESS_AUDIENCE, "access_descriptor", "user-1"); + let refresh = descriptor(USER_RESUMPTION_AUDIENCE, "resumption", "user-1"); client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "access_token".to_string(), - Some("refresh_token".to_string()), - ) + .set_tokens(access.clone(), Some(refresh.clone())) .unwrap(); - - let device = PushDevice { - id: device_id, - object: "push.device".to_string(), - installation_id: Uuid::new_v4(), - platform: PushPlatform::Android, - provider: PushProvider::Fcm, - environment: PushEnvironment::Prod, - app_id: "ai.trymaple.android".to_string(), - key_algorithm: PushKeyAlgorithm::P256EcdhV1, - supports_encrypted_preview: false, - supports_background_processing: true, - last_seen_at: now, - created_at: now, - updated_at: now, - }; - let list_response = PushDeviceListResponse { - object: "list".to_string(), - data: vec![device.clone()], - }; - let deleted_response = DeletedPushDeviceResponse { - id: device_id, - object: "push.device.deleted".to_string(), - deleted: true, - }; - - Mock::given(method("GET")) - .and(path("/v1/push/devices")) - .and(header("authorization", "Bearer access_token")) - .and(header("x-session-id", session_id.to_string())) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(encrypted_response(&session_key, &list_response)), - ) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("DELETE")) - .and(path(format!("/v1/push/devices/{}", device_id))) - .and(header("authorization", "Bearer access_token")) - .and(header("x-session-id", session_id.to_string())) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(encrypted_response(&session_key, &deleted_response)), - ) - .expect(1) - .mount(&mock_server) - .await; - - let listed = client.list_push_devices().await.unwrap(); - let deleted = client.revoke_push_device(device_id).await.unwrap(); - - assert_eq!(listed, list_response); - assert_eq!(deleted, deleted_response); + assert_eq!(client.get_access_token().unwrap(), Some(access)); + assert_eq!(client.get_refresh_token().unwrap(), Some(refresh)); } #[tokio::test] - async fn test_logout_with_push_device_id_sends_cleanup_hint() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [23u8; 32]; - let push_device_id = Uuid::new_v4(); - - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client + async fn operation_start_epoch_cannot_cross_to_a_new_principal() { + let client = OpenSecretClient::new("http://localhost:3000").unwrap(); + let initial = client .session_manager - .set_tokens( - "access_token".to_string(), - Some("refresh_token".to_string()), + .get_credential_snapshot() + .unwrap() + .auth_epoch; + let user_a = "123e4567-e89b-12d3-a456-426614174001"; + let user_b = "123e4567-e89b-12d3-a456-426614174002"; + let user_a_epoch = client + .session_manager + .replace_user_tokens_and_session_if_epoch( + &initial, + descriptor(USER_ACCESS_AUDIENCE, "access_descriptor", user_a), + Some(descriptor(USER_RESUMPTION_AUDIENCE, "resumption", user_a)), + user_a.to_string(), + Arc::new( + V2Session::from_master_for_test( + Uuid::from_bytes([0x41; 16]), + [0x41; 32], + u64::MAX, + ) + .unwrap(), + ), ) - .unwrap(); - - Mock::given(method("POST")) - .and(path("/logout")) - .and(MissingHeaderMatcher("authorization")) - .and(header("x-session-id", session_id.to_string())) - .respond_with(LogoutWithPushDeviceResponder { - session_key, - expected_push_device_id: push_device_id, - }) - .expect(1) - .mount(&mock_server) - .await; - - client - .logout_with_push_device_id(push_device_id) - .await - .unwrap(); - - assert!(client.get_session_id().unwrap().is_none()); - assert!(client.get_access_token().unwrap().is_none()); - assert!(client.get_refresh_token().unwrap().is_none()); - } - - #[tokio::test] - async fn test_change_password_preserves_refresh_token_when_response_omits_one() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [24u8; 32]; + .unwrap() + .expect("install user A"); - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client + let user_b_epoch = client .session_manager - .set_tokens( - "old_access_token".to_string(), - Some("old_refresh_token".to_string()), + .replace_user_tokens( + descriptor(USER_ACCESS_AUDIENCE, "access_descriptor", user_b), + Some(descriptor(USER_RESUMPTION_AUDIENCE, "resumption", user_b)), + Some(user_b.to_string()), ) .unwrap(); - - Mock::given(method("POST")) - .and(path("/protected/change_password")) - .and(header("authorization", "Bearer old_access_token")) - .and(header("x-session-id", session_id.to_string())) - .respond_with(ResponseTemplate::new(200).set_body_json(encrypted_response( - &session_key, - &json!({ - "message": "updated", - "access_token": "new_access_token" - }), - ))) - .expect(1) - .mount(&mock_server) - .await; - - client - .change_password("old-credential".to_string(), "new-credential".to_string()) - .await - .unwrap(); - - assert_eq!( - client.get_access_token().unwrap().as_deref(), - Some("new_access_token") - ); - assert_eq!( - client.get_refresh_token().unwrap().as_deref(), - Some("old_refresh_token") - ); - } - - #[tokio::test] - async fn test_authenticated_calls_refresh_and_retry_seamlessly() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [7u8; 32]; - let expired_access = "expired_access"; - let new_access = "new_access"; - let new_refresh = "new_refresh"; - let expired_header = format!("Bearer {}", expired_access); - let fresh_header = format!("Bearer {}", new_access); - - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); client .session_manager - .set_tokens( - expired_access.to_string(), - Some("refresh_token".to_string()), - ) - .unwrap(); - - Mock::given(method("GET")) - .and(path("/protected/user")) - .and(header("authorization", &expired_header)) - .and(header("x-session-id", session_id.to_string())) - .respond_with( - ResponseTemplate::new(401).set_body_json(json!({ "message": "jwt expired" })), + .replace_user_tokens_and_session_if_epoch( + &user_b_epoch, + descriptor(USER_ACCESS_AUDIENCE, "access_descriptor", user_b), + Some(descriptor(USER_RESUMPTION_AUDIENCE, "resumption", user_b)), + user_b.to_string(), + Arc::new( + V2Session::from_master_for_test( + Uuid::from_bytes([0x42; 16]), + [0x42; 32], + u64::MAX, + ) + .unwrap(), + ), ) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("POST")) - .and(path("/refresh")) - .and(MissingHeaderMatcher("authorization")) - .and(header("x-session-id", session_id.to_string())) - .respond_with(ResponseTemplate::new(200).set_body_json(encrypted_response( - &session_key, - &json!({ - "access_token": new_access, - "refresh_token": new_refresh, - }), - ))) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("GET")) - .and(path("/protected/user")) - .and(header("authorization", &fresh_header)) - .and(header("x-session-id", session_id.to_string())) - .respond_with(ResponseTemplate::new(200).set_body_json(encrypted_response( - &session_key, - &json!({ - "user": { - "id": Uuid::new_v4(), - "name": null, - "email": "sdk@test.dev", - "email_verified": true, - "login_method": "email", - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z" - } - }), - ))) - .expect(1) - .mount(&mock_server) - .await; - - let response = client.get_user().await.unwrap(); + .unwrap() + .expect("install user B"); - assert_eq!(response.user.email.as_deref(), Some("sdk@test.dev")); - assert_eq!( - client.get_access_token().unwrap().as_deref(), - Some(new_access) - ); + let error = match client.ensure_v2_user_session(&user_a_epoch).await { + Ok(_) => panic!("stale operation must not acquire user B's session"), + Err(error) => error, + }; + assert!(matches!(error, Error::Session(_))); assert_eq!( - client.get_refresh_token().unwrap().as_deref(), - Some(new_refresh) + client + .session_manager + .get_credential_snapshot() + .unwrap() + .auth_epoch + .principal + .as_deref(), + Some(user_b) ); } - #[tokio::test] - async fn concurrent_authenticated_401s_share_one_refresh() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [39u8; 32]; - - client + #[test] + fn api_key_operation_start_generation_fails_closed_after_authority_change() { + let client = + OpenSecretClient::new_with_api_key("http://localhost:3000", "api-key-a".to_string()) + .unwrap(); + let generation = client .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "expired_access".to_string(), - Some("refresh_token".to_string()), - ) - .unwrap(); - - Mock::given(method("GET")) - .and(path("/protected/user")) - .and(header("authorization", "Bearer expired_access")) - .respond_with( - ResponseTemplate::new(401) - .set_delay(Duration::from_millis(50)) - .set_body_json(json!({ "message": "jwt expired" })), - ) - .expect(2) - .mount(&mock_server) - .await; - - Mock::given(method("POST")) - .and(path("/refresh")) - .and(MissingHeaderMatcher("authorization")) - .respond_with( - ResponseTemplate::new(200) - .set_delay(Duration::from_millis(100)) - .set_body_json(encrypted_response( - &session_key, - &json!({ - "access_token": "fresh_access", - "refresh_token": "fresh_refresh", - }), - )), - ) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("GET")) - .and(path("/protected/user")) - .and(header("authorization", "Bearer fresh_access")) - .respond_with(ResponseTemplate::new(200).set_body_json(encrypted_response( - &session_key, - &json!({ - "user": { - "id": Uuid::new_v4(), - "name": null, - "email": "sdk@test.dev", - "email_verified": true, - "login_method": "email", - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z" - } - }), - ))) - .expect(2) - .mount(&mock_server) - .await; - - let (first, second) = tokio::join!(client.get_user(), client.get_user()); - assert_eq!(first.unwrap().user.email.as_deref(), Some("sdk@test.dev")); - assert_eq!(second.unwrap().user.email.as_deref(), Some("sdk@test.dev")); - assert_eq!( - client.get_access_token().unwrap().as_deref(), - Some("fresh_access") - ); - assert_eq!( - client.get_refresh_token().unwrap().as_deref(), - Some("fresh_refresh") - ); - mock_server.verify().await; - } - - #[tokio::test] - async fn v1_generic_400_and_401_do_not_recover() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [54u8; 32]; - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "access_token".to_string(), - Some("refresh_token".to_string()), - ) - .unwrap(); - - for (endpoint, status) in [("/generic-400", 400), ("/generic-401", 401)] { - Mock::given(method("GET")) - .and(path(endpoint)) - .and(header("authorization", "Bearer access_token")) - .and(header("x-session-id", session_id.to_string())) - .respond_with(v1_error_response(status, None, "generic error")) - .expect(1) - .mount(&mock_server) - .await; - } - Mock::given(method("GET")) - .and(PathPrefixMatcher("/attestation/")) - .respond_with(ResponseTemplate::new(500)) - .expect(0) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/refresh")) - .respond_with(ResponseTemplate::new(500)) - .expect(0) - .mount(&mock_server) - .await; - - for (endpoint, expected_status) in [("/generic-400", 400), ("/generic-401", 401)] { - let error = client - .authenticated_api_call::<(), serde_json::Value>(endpoint, "GET", None) - .await - .unwrap_err(); - assert!(matches!( - error, - Error::Api { status, .. } if status == expected_status - )); - } - mock_server.verify().await; - } - - #[tokio::test] - async fn corrupt_successful_response_is_not_replayed() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [55u8; 32]; - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "access_token".to_string(), - Some("refresh_token".to_string()), - ) - .unwrap(); - - Mock::given(method("GET")) - .and(path("/corrupt-success")) - .and(header("authorization", "Bearer access_token")) - .and(header("x-session-id", session_id.to_string())) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "encrypted": "AAAA" - }))) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("GET")) - .and(PathPrefixMatcher("/attestation/")) - .respond_with(ResponseTemplate::new(500)) - .expect(0) - .mount(&mock_server) - .await; - - let error = client - .authenticated_api_call::<(), serde_json::Value>("/corrupt-success", "GET", None) - .await - .unwrap_err(); - assert!(matches!(error, Error::Decryption(_))); - mock_server.verify().await; - } - - #[tokio::test] - async fn target_replay_budget_is_shared_across_recovery_reasons() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let stale_session_id = Uuid::new_v4(); - let stale_session_key = [56u8; 32]; - let server_secret_key = [57u8; 32]; - let server_public_key = - x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(server_secret_key)); - let fresh_session_id = Uuid::new_v4(); - let fresh_session_key = [58u8; 32]; - client - .session_manager - .set_session(stale_session_id, stale_session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "expired_access".to_string(), - Some("refresh_token".to_string()), - ) - .unwrap(); - - Mock::given(method("GET")) - .and(path("/mixed-recovery")) - .and(header("authorization", "Bearer expired_access")) - .and(header("x-session-id", stale_session_id.to_string())) - .respond_with(v1_error_response( - 400, - Some("session_not_found"), - "stale session", - )) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("GET")) - .and(PathPrefixMatcher("/attestation/")) - .respond_with(AttestationResponder { - server_public_key: server_public_key.to_bytes(), - }) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/key_exchange")) - .respond_with(KeyExchangeResponder { - server_secret_key, - session_key: fresh_session_key, - session_id: fresh_session_id.to_string(), - }) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("GET")) - .and(path("/mixed-recovery")) - .and(header("authorization", "Bearer expired_access")) - .and(header("x-session-id", fresh_session_id.to_string())) - .respond_with(v1_error_response( - 401, - Some("access_token_expired"), - "expired access token", - )) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/refresh")) - .respond_with(ResponseTemplate::new(500)) - .expect(0) - .mount(&mock_server) - .await; - - let error = client - .authenticated_api_call::<(), serde_json::Value>("/mixed-recovery", "GET", None) - .await - .unwrap_err(); - assert!(matches!(error, Error::Api { status: 401, .. })); - mock_server.verify().await; - } - - #[tokio::test] - async fn expired_target_with_stale_refresh_session_repairs_each_layer_once() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let stale_session_id = Uuid::new_v4(); - let stale_session_key = [59u8; 32]; - let server_secret_key = [60u8; 32]; - let server_public_key = - x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(server_secret_key)); - let fresh_session_id = Uuid::new_v4(); - let fresh_session_key = [61u8; 32]; - client - .session_manager - .set_session(stale_session_id, stale_session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "expired_access".to_string(), - Some("refresh_token".to_string()), - ) - .unwrap(); - - Mock::given(method("GET")) - .and(path("/long-idle")) - .and(header("authorization", "Bearer expired_access")) - .and(header("x-session-id", stale_session_id.to_string())) - .respond_with(v1_error_response( - 401, - Some("access_token_expired"), - "expired access token", - )) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/refresh")) - .and(MissingHeaderMatcher("authorization")) - .and(header("x-session-id", stale_session_id.to_string())) - .and(EncryptedJsonBodyMatcher { - session_key: stale_session_key, - expected: json!({ "refresh_token": "refresh_token" }), - }) - .respond_with(v1_error_response( - 400, - Some("session_not_found"), - "stale session", - )) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("GET")) - .and(PathPrefixMatcher("/attestation/")) - .respond_with(AttestationResponder { - server_public_key: server_public_key.to_bytes(), - }) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/key_exchange")) - .and(MissingHeaderMatcher("authorization")) - .respond_with(KeyExchangeResponder { - server_secret_key, - session_key: fresh_session_key, - session_id: fresh_session_id.to_string(), - }) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/refresh")) - .and(MissingHeaderMatcher("authorization")) - .and(header("x-session-id", fresh_session_id.to_string())) - .and(EncryptedJsonBodyMatcher { - session_key: fresh_session_key, - expected: json!({ "refresh_token": "refresh_token" }), - }) - .respond_with(ResponseTemplate::new(200).set_body_json(encrypted_response( - &fresh_session_key, - &json!({ - "access_token": "fresh_access", - "refresh_token": "fresh_refresh" - }), - ))) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("GET")) - .and(path("/long-idle")) - .and(header("authorization", "Bearer fresh_access")) - .and(header("x-session-id", fresh_session_id.to_string())) - .respond_with(ResponseTemplate::new(200).set_body_json(encrypted_response( - &fresh_session_key, - &json!({ "ok": true }), - ))) - .expect(1) - .mount(&mock_server) - .await; - - let response = client - .authenticated_api_call::<(), serde_json::Value>("/long-idle", "GET", None) - .await - .unwrap(); - assert_eq!(response, json!({ "ok": true })); - assert_eq!( - client.get_access_token().unwrap().as_deref(), - Some("fresh_access") - ); - assert_eq!(client.get_session_id().unwrap(), Some(fresh_session_id)); - mock_server.verify().await; - } - - #[tokio::test] - async fn establishing_a_missing_local_session_does_not_consume_target_replay() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let server_secret_key = [62u8; 32]; - let server_public_key = - x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(server_secret_key)); - let session_id = Uuid::new_v4(); - let session_key = [63u8; 32]; - client - .session_manager - .set_tokens( - "expired_access".to_string(), - Some("refresh_token".to_string()), - ) - .unwrap(); - - Mock::given(method("GET")) - .and(PathPrefixMatcher("/attestation/")) - .respond_with(AttestationResponder { - server_public_key: server_public_key.to_bytes(), - }) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/key_exchange")) - .respond_with(KeyExchangeResponder { - server_secret_key, - session_key, - session_id: session_id.to_string(), - }) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("GET")) - .and(path("/missing-local-session")) - .and(header("authorization", "Bearer expired_access")) - .and(header("x-session-id", session_id.to_string())) - .respond_with(v1_error_response( - 401, - Some("access_token_expired"), - "expired access token", - )) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/refresh")) - .and(MissingHeaderMatcher("authorization")) - .and(header("x-session-id", session_id.to_string())) - .and(EncryptedJsonBodyMatcher { - session_key, - expected: json!({ "refresh_token": "refresh_token" }), - }) - .respond_with(ResponseTemplate::new(200).set_body_json(encrypted_response( - &session_key, - &json!({ - "access_token": "fresh_access", - "refresh_token": "fresh_refresh" - }), - ))) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("GET")) - .and(path("/missing-local-session")) - .and(header("authorization", "Bearer fresh_access")) - .and(header("x-session-id", session_id.to_string())) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(encrypted_response(&session_key, &json!({ "ok": true }))), - ) - .expect(1) - .mount(&mock_server) - .await; - - let response = client - .authenticated_api_call::<(), serde_json::Value>("/missing-local-session", "GET", None) - .await - .unwrap(); - assert_eq!(response, json!({ "ok": true })); - mock_server.verify().await; - } - - #[tokio::test] - async fn test_corrupted_access_token_recovers_via_refresh_on_next_call() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [5u8; 32]; - let original_access = "valid_access"; - let original_refresh = "valid_refresh"; - let corrupted_access = "malformed_access"; - let refreshed_access = "refreshed_access"; - let refreshed_refresh = "refreshed_refresh"; - - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - - Mock::given(method("POST")) - .and(path("/login")) - .and(MissingHeaderMatcher("authorization")) - .and(header("x-session-id", session_id.to_string())) - .respond_with(ResponseTemplate::new(200).set_body_json(encrypted_response( - &session_key, - &json!({ - "id": Uuid::new_v4(), - "email": "sdk@test.dev", - "access_token": original_access, - "refresh_token": original_refresh, - }), - ))) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("GET")) - .and(path("/protected/user")) - .and(header( - "authorization", - format!("Bearer {}", original_access), - )) - .and(header("x-session-id", session_id.to_string())) - .respond_with(ResponseTemplate::new(200).set_body_json(encrypted_response( - &session_key, - &json!({ - "user": { - "id": Uuid::new_v4(), - "name": null, - "email": "sdk@test.dev", - "email_verified": true, - "login_method": "email", - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z" - } - }), - ))) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("GET")) - .and(path("/protected/user")) - .and(header( - "authorization", - format!("Bearer {}", corrupted_access), - )) - .and(header("x-session-id", session_id.to_string())) - .respond_with( - ResponseTemplate::new(401).set_body_json(json!({ "message": "invalid jwt" })), - ) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("POST")) - .and(path("/refresh")) - .and(MissingHeaderMatcher("authorization")) - .and(header("x-session-id", session_id.to_string())) - .respond_with(ResponseTemplate::new(200).set_body_json(encrypted_response( - &session_key, - &json!({ - "access_token": refreshed_access, - "refresh_token": refreshed_refresh, - }), - ))) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("GET")) - .and(path("/protected/user")) - .and(header( - "authorization", - format!("Bearer {}", refreshed_access), - )) - .and(header("x-session-id", session_id.to_string())) - .respond_with(ResponseTemplate::new(200).set_body_json(encrypted_response( - &session_key, - &json!({ - "user": { - "id": Uuid::new_v4(), - "name": null, - "email": "sdk@test.dev", - "email_verified": true, - "login_method": "email", - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z" - } - }), - ))) - .expect(1) - .mount(&mock_server) - .await; - - client - .login( - "sdk@test.dev".to_string(), - "password".to_string(), - Uuid::new_v4(), - ) - .await - .unwrap(); - - let initial_user = client.get_user().await.unwrap(); - assert_eq!(initial_user.user.email.as_deref(), Some("sdk@test.dev")); - - client - .session_manager - .update_access_token(corrupted_access.to_string()) - .unwrap(); - - let recovered_user = client.get_user().await.unwrap(); - - assert_eq!(recovered_user.user.email.as_deref(), Some("sdk@test.dev")); - assert_eq!( - client.get_access_token().unwrap().as_deref(), - Some(refreshed_access) - ); - assert_eq!( - client.get_refresh_token().unwrap().as_deref(), - Some(refreshed_refresh) - ); - } - - #[tokio::test] - async fn test_streaming_completion_preserves_reasoning_content() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [13u8; 32]; - - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "access_token".to_string(), - Some("refresh_token".to_string()), - ) - .unwrap(); - - let sse_body = format!( - "{}data: [DONE]\n\n", - encrypted_sse_data( - &session_key, - &json!({ - "id": "chatcmpl-test", - "object": "chat.completion.chunk", - "created": 1, - "model": "kimi-k2-5", - "choices": [{ - "index": 0, - "delta": { - "reasoning_content": "2 + 2 = 4" - }, - "finish_reason": null - }] - }) - ) - ); - - Mock::given(method("POST")) - .and(path("/v1/chat/completions")) - .and(header("authorization", "Bearer access_token")) - .and(header("x-session-id", session_id.to_string())) - .and(EncryptedJsonBodyMatcher { - session_key, - expected: json!({ - "model": "kimi-k2-5", - "messages": [{"role": "user", "content": "What is 2+2?"}], - "temperature": 0.0, - "max_tokens": 100, - "stream": true, - "stream_options": {"include_usage": true} - }), - }) - .respond_with(ResponseTemplate::new(200).set_body_raw(sse_body, "text/event-stream")) - .expect(1) - .mount(&mock_server) - .await; - - let request = ChatCompletionRequest { - model: "kimi-k2-5".to_string(), - messages: vec![ChatMessage { - role: "user".to_string(), - content: serde_json::json!("What is 2+2?"), - tool_calls: None, - reasoning_content: None, - }], - temperature: Some(0.0), - max_tokens: Some(100), - stream: Some(true), - stream_options: None, - tools: None, - tool_choice: None, - }; - - let mut stream = client.create_chat_completion_stream(request).await.unwrap(); - let chunk = stream.next().await.unwrap().unwrap(); - - assert_eq!( - chunk.0["choices"][0]["delta"]["reasoning_content"].as_str(), - Some("2 + 2 = 4") - ); - assert!(stream.next().await.is_none()); - } - - #[tokio::test] - async fn inference_transport_preserves_raw_request_and_response_bytes() { - let mock_server = MockServer::start().await; - let client = - OpenSecretClient::new_with_api_key(mock_server.uri(), "real_api_key".to_string()) - .unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [27u8; 32]; - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - - let request_body = Bytes::from_static( - br#"{ "z": 184467440737095516160000000000000, "stream":false, "a":[1, 2] } -"#, - ); - let response_body = Bytes::from_static( - br#"{ "provider": {"huge":184467440737095516160000000000001}, "a": 1 } -"#, - ); - - Mock::given(method("POST")) - .and(path("/v1/chat/completions")) - .and(query_param("trace", "one two")) - .and(header("authorization", "Bearer real_api_key")) - .and(header("x-session-id", session_id.to_string())) - .and(header("content-type", "application/json")) - .and(header("x-provider-beta", "raw-v2")) - .and(MissingHeaderMatcher("x-remove")) - .and(MissingHeaderMatcher("content-encoding")) - .and(MissingHeaderMatcher("accept-encoding")) - .and(MissingHeaderMatcher("content-md5")) - .and(EncryptedBytesBodyMatcher { - session_key, - expected: request_body.clone(), - }) - .respond_with( - ResponseTemplate::new(201) - .insert_header("content-type", "application/json") - .insert_header("x-provider-result", "kept") - .insert_header("connection", "x-remove-response") - .insert_header("x-remove-response", "gone") - .insert_header("content-encoding", "identity") - .set_body_json(encrypted_response_bytes(&session_key, &response_body)), - ) - .expect(1) - .mount(&mock_server) - .await; - - let request = HttpRequest::builder() - .method(http::Method::POST) - .uri("/v1/chat/completions?trace=one%20two") - .header(header::AUTHORIZATION, "Bearer caller_must_not_control_this") - .header("x-session-id", "caller_must_not_control_this") - .header(header::CONTENT_TYPE, "application/custom") - .header(header::CONTENT_ENCODING, "gzip") - .header(header::ACCEPT_ENCODING, "gzip, br") - .header("content-md5", "caller-body-digest") - .header(header::CONNECTION, "x-remove") - .header("x-remove", "gone") - .header("x-provider-beta", "raw-v2") - .body(request_body) - .unwrap(); - let response = client.send_inference_request(request).await.unwrap(); - - assert_eq!(response.status(), http::StatusCode::CREATED); - assert_eq!(response.headers().get("x-provider-result").unwrap(), "kept"); - assert!(!response.headers().contains_key(header::CONTENT_LENGTH)); - assert!(!response.headers().contains_key(header::CONTENT_ENCODING)); - assert!(!response.headers().contains_key(header::CONNECTION)); - assert!(!response.headers().contains_key("x-remove-response")); - assert_eq!( - collect_response_body(response.into_body()).await.unwrap(), - response_body - ); - } - - #[tokio::test] - async fn inference_transport_rejects_absolute_and_non_inference_routes() { - let client = OpenSecretClient::new("http://localhost:3000").unwrap(); - for (method, uri) in [ - ( - http::Method::POST, - "https://example.test/v1/chat/completions", - ), - (http::Method::POST, "/v1/responses"), - (http::Method::GET, "/v1/chat/completions"), - (http::Method::POST, "/protected/user"), - ] { - let request = HttpRequest::builder() - .method(method) - .uri(uri) - .body(Bytes::new()) - .unwrap(); - assert!(matches!( - client.send_inference_request(request).await, - Err(Error::Configuration(_)) - )); - } - } - - #[tokio::test] - async fn inference_transport_preserves_final_plaintext_error_after_refresh() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [28u8; 32]; - let request_body = Bytes::from_static(br#"{"model":"test","messages":[]}"#); - let error_body = - Bytes::from_static(br#"{"error":{"type":"rate_limit","n":999999999999999999999}}"#); - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "expired_access".to_string(), - Some("refresh_token".to_string()), - ) - .unwrap(); - - Mock::given(method("POST")) - .and(path("/v1/chat/completions")) - .and(header("authorization", "Bearer expired_access")) - .and(EncryptedBytesBodyMatcher { - session_key, - expected: request_body.clone(), - }) - .respond_with(ResponseTemplate::new(401).set_body_string("jwt expired")) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/refresh")) - .and(MissingHeaderMatcher("authorization")) - .respond_with(ResponseTemplate::new(200).set_body_json(encrypted_response( - &session_key, - &json!({ - "access_token": "fresh_access", - "refresh_token": "fresh_refresh" - }), - ))) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/v1/chat/completions")) - .and(header("authorization", "Bearer fresh_access")) - .and(EncryptedBytesBodyMatcher { - session_key, - expected: request_body.clone(), - }) - .respond_with( - ResponseTemplate::new(429) - .insert_header("x-request-id", "provider-429") - .set_body_bytes(error_body.clone()), - ) - .expect(1) - .mount(&mock_server) - .await; - - let request = HttpRequest::builder() - .method(http::Method::POST) - .uri("/v1/chat/completions") - .body(request_body) - .unwrap(); - let response = client.send_inference_request(request).await.unwrap(); - - assert_eq!(response.status(), http::StatusCode::TOO_MANY_REQUESTS); - assert_eq!( - response.headers().get("x-request-id").unwrap(), - "provider-429" - ); - assert_eq!( - collect_response_body(response.into_body()).await.unwrap(), - error_body - ); - } - - #[tokio::test] - async fn concurrent_inference_401s_share_one_refresh() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [40u8; 32]; - let request_body = Bytes::from_static(br#"{"model":"test","messages":[]}"#); - let response_body = Bytes::from_static(br#"{"id":"completion-ok"}"#); - - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "expired_access".to_string(), - Some("refresh_token".to_string()), - ) - .unwrap(); - - Mock::given(method("POST")) - .and(path("/v1/chat/completions")) - .and(header("authorization", "Bearer expired_access")) - .and(EncryptedBytesBodyMatcher { - session_key, - expected: request_body.clone(), - }) - .respond_with( - ResponseTemplate::new(401) - .insert_header(ERROR_CONTRACT_HEADER, "1") - .insert_header(ERROR_CODE_HEADER, "access_token_expired") - .set_delay(Duration::from_millis(50)) - .set_body_string("jwt expired"), - ) - .expect(2) - .mount(&mock_server) - .await; - - Mock::given(method("POST")) - .and(path("/refresh")) - .and(MissingHeaderMatcher("authorization")) - .respond_with( - ResponseTemplate::new(200) - .set_delay(Duration::from_millis(100)) - .set_body_json(encrypted_response( - &session_key, - &json!({ - "access_token": "fresh_access", - "refresh_token": "fresh_refresh", - }), - )), - ) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("POST")) - .and(path("/v1/chat/completions")) - .and(header("authorization", "Bearer fresh_access")) - .and(EncryptedBytesBodyMatcher { - session_key, - expected: request_body.clone(), - }) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(encrypted_response_bytes(&session_key, &response_body)), - ) - .expect(2) - .mount(&mock_server) - .await; - - let make_request = || { - HttpRequest::builder() - .method(http::Method::POST) - .uri("/v1/chat/completions") - .body(request_body.clone()) - .unwrap() - }; - let (first, second) = tokio::join!( - client.send_inference_request(make_request()), - client.send_inference_request(make_request()) - ); - - let first = first.unwrap(); - let second = second.unwrap(); - assert_eq!(first.status(), http::StatusCode::OK); - assert_eq!(second.status(), http::StatusCode::OK); - assert_eq!( - collect_response_body(first.into_body()).await.unwrap(), - response_body - ); - assert_eq!( - collect_response_body(second.into_body()).await.unwrap(), - response_body - ); - assert_eq!( - client.get_access_token().unwrap().as_deref(), - Some("fresh_access") - ); - assert_eq!( - client.get_refresh_token().unwrap().as_deref(), - Some("fresh_refresh") - ); - mock_server.verify().await; - } - - #[tokio::test] - async fn inference_401_retries_when_auth_source_changes_from_api_key_to_jwt() { - let mock_server = MockServer::start().await; - let client = - OpenSecretClient::new_with_api_key(mock_server.uri(), "old_api_key".to_string()) - .unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [46u8; 32]; - let request_body = Bytes::from_static(br#"{"model":"test","messages":[]}"#); - let response_body = Bytes::from_static(br#"{"id":"jwt-completion"}"#); - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens("jwt_access".to_string(), Some("jwt_refresh".to_string())) - .unwrap(); - - Mock::given(method("POST")) - .and(path("/v1/chat/completions")) - .and(header("authorization", "Bearer old_api_key")) - .respond_with( - ResponseTemplate::new(401) - .set_delay(Duration::from_millis(100)) - .set_body_string("api key rejected"), - ) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/v1/chat/completions")) - .and(header("authorization", "Bearer jwt_access")) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(encrypted_response_bytes(&session_key, &response_body)), - ) - .expect(1) - .mount(&mock_server) - .await; - - let request = HttpRequest::builder() - .method(http::Method::POST) - .uri("/v1/chat/completions") - .body(request_body) - .unwrap(); - let (response, clear_result) = - tokio::join!(client.send_inference_request(request), async { - tokio::time::sleep(Duration::from_millis(25)).await; - client.clear_api_key() - }); - - clear_result.unwrap(); - let response = response.unwrap(); - assert_eq!(response.status(), http::StatusCode::OK); - assert_eq!( - collect_response_body(response.into_body()).await.unwrap(), - response_body - ); - mock_server.verify().await; - } - - #[tokio::test] - async fn inference_transport_decrypts_non_success_encrypted_body() { - let mock_server = MockServer::start().await; - let client = - OpenSecretClient::new_with_api_key(mock_server.uri(), "api_key".to_string()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [29u8; 32]; - let error_body = Bytes::from_static(br#"{ "error": "provider rejected", "extra": 7 }"#); - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - - Mock::given(method("POST")) - .and(path("/v1/embeddings")) - .respond_with( - ResponseTemplate::new(422) - .set_body_json(encrypted_response_bytes(&session_key, &error_body)), - ) - .expect(1) - .mount(&mock_server) - .await; - - let request = HttpRequest::builder() - .method(http::Method::POST) - .uri("/v1/embeddings") - .body(Bytes::from_static(br#"{"model":"x","input":"y"}"#)) - .unwrap(); - let response = client.send_inference_request(request).await.unwrap(); - - assert_eq!(response.status(), http::StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!( - collect_response_body(response.into_body()).await.unwrap(), - error_body - ); - } - - #[tokio::test] - async fn inference_transport_preserves_non_envelope_error_with_encrypted_field() { - let mock_server = MockServer::start().await; - let client = - OpenSecretClient::new_with_api_key(mock_server.uri(), "api_key".to_string()).unwrap(); - let session_key = [36u8; 32]; - let error_body = Bytes::from( - serde_json::to_vec(&json!({ - "encrypted": BASE64.encode([0u8; 28]), - "message": "plain backend error" - })) - .unwrap(), - ); - client - .session_manager - .set_session(Uuid::new_v4(), session_key) - .unwrap(); - - Mock::given(method("POST")) - .and(path("/v1/embeddings")) - .respond_with(ResponseTemplate::new(422).set_body_bytes(error_body.clone())) - .expect(1) - .mount(&mock_server) - .await; - - let request = HttpRequest::builder() - .method(http::Method::POST) - .uri("/v1/embeddings") - .body(Bytes::from_static(br#"{"model":"x","input":"y"}"#)) - .unwrap(); - let response = client.send_inference_request(request).await.unwrap(); - - assert_eq!(response.status(), http::StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!( - collect_response_body(response.into_body()).await.unwrap(), - error_body - ); - } - - #[tokio::test] - async fn inference_transport_establishes_attestation_and_replays_exact_bytes() { - let mock_server = MockServer::start().await; - let client = - OpenSecretClient::new_with_api_key(mock_server.uri(), "api_key".to_string()).unwrap(); - let stale_session_id = Uuid::new_v4(); - let stale_session_key = [33u8; 32]; - let server_secret_key = [34u8; 32]; - let server_public_key = - x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(server_secret_key)); - let fresh_session_key = [35u8; 32]; - let fresh_session_id = Uuid::new_v4(); - let request_body = Bytes::from_static(br#"{ "model":"x", "input":"exact bytes" }"#); - let response_body = Bytes::from_static(br#"{ "ok": true }"#); - client - .session_manager - .set_session(stale_session_id, stale_session_key) - .unwrap(); - - Mock::given(method("POST")) - .and(path("/v1/embeddings")) - .and(query_param("trace", "one two")) - .and(header("authorization", "Bearer api_key")) - .and(header("x-session-id", stale_session_id.to_string())) - .and(header("x-provider-beta", "raw-replay")) - .and(EncryptedBytesBodyMatcher { - session_key: stale_session_key, - expected: request_body.clone(), - }) - .respond_with(v1_error_response( - 400, - Some("session_not_found"), - "stale session", - )) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("GET")) - .and(PathPrefixMatcher("/attestation/")) - .respond_with(AttestationResponder { - server_public_key: server_public_key.to_bytes(), - }) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/key_exchange")) - .and(MissingHeaderMatcher("authorization")) - .respond_with(KeyExchangeResponder { - server_secret_key, - session_key: fresh_session_key, - session_id: fresh_session_id.to_string(), - }) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/v1/embeddings")) - .and(query_param("trace", "one two")) - .and(header("authorization", "Bearer api_key")) - .and(header("x-session-id", fresh_session_id.to_string())) - .and(header("x-provider-beta", "raw-replay")) - .and(EncryptedBytesBodyMatcher { - session_key: fresh_session_key, - expected: request_body.clone(), - }) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(encrypted_response_bytes(&fresh_session_key, &response_body)), - ) - .expect(1) - .mount(&mock_server) - .await; - - let request = HttpRequest::builder() - .method(http::Method::POST) - .uri("/v1/embeddings?trace=one%20two") - .header("x-provider-beta", "raw-replay") - .body(request_body) - .unwrap(); - let response = client.send_inference_request(request).await.unwrap(); - - assert_eq!( - collect_response_body(response.into_body()).await.unwrap(), - response_body - ); - } - - #[tokio::test] - async fn inference_sse_transport_preserves_framing_across_arbitrary_chunks() { - let session_key = [30u8; 32]; - let decrypted_payload = - br#"{ "delta": {"huge":184467440737095516160000000000000}, "text":"hi" }"#; - let encrypted_payload = encrypted_sse_bytes(&session_key, decrypted_payload); - let encrypted_sse = format!( - ": heartbeat\r\nevent: chunk\r\nid: provider-7\r\nretry: 1500\r\ndata: {encrypted_payload}\r\n\r\n: provider-heartbeat\n\ndata:\n\ndata: [DONE]\n\n" - ); - let expected = format!( - ": heartbeat\r\nevent: chunk\r\nid: provider-7\r\nretry: 1500\r\ndata: {}\r\n\r\n: provider-heartbeat\n\ndata:\n\ndata: [DONE]\n\n", - String::from_utf8_lossy(decrypted_payload) - ); - let chunks = encrypted_sse - .as_bytes() - .chunks(3) - .map(|chunk| Ok(Bytes::copy_from_slice(chunk))) - .collect::>>(); - let source: OpenSecretResponseBody = Box::pin(futures::stream::iter(chunks)); - - let actual = collect_response_body(decrypt_sse_stream(source, session_key)) - .await - .unwrap(); - - assert_eq!(actual, expected.as_bytes()); - assert_eq!( - actual - .windows(6) - .filter(|window| *window == b"[DONE]") - .count(), - 1 - ); - } - - #[tokio::test] - async fn inference_sse_transport_rejects_plaintext_completion_chunk() { - let session_key = [47u8; 32]; - let source: OpenSecretResponseBody = Box::pin(futures::stream::iter([Ok( - Bytes::from_static( - br#"data: {"id":"chatcmpl-injected","choices":[{"delta":{"content":"untrusted"}}]}\n\n"#, - ), - )])); - - let error = collect_response_body(decrypt_sse_stream(source, session_key)) - .await - .unwrap_err(); - - assert!(matches!( - error, - Error::InvalidResponse(message) if message.contains("not valid encrypted payload") - )); - } - - #[tokio::test] - async fn inference_sse_transport_rejects_short_base64_payload() { - let session_key = [48u8; 32]; - let source: OpenSecretResponseBody = Box::pin(futures::stream::iter([Ok( - Bytes::from_static(b"data: YWJj\n\n"), - )])); - - let error = collect_response_body(decrypt_sse_stream(source, session_key)) - .await - .unwrap_err(); - - assert!(matches!( - error, - Error::InvalidResponse(message) if message.contains("encrypted payload minimum") - )); - } - - #[tokio::test] - async fn inference_sse_transport_reports_corrupt_ciphertext() { - let session_key = [31u8; 32]; - let mut encrypted = crypto::encrypt_data(&session_key, br#"{"delta":"x"}"#).unwrap(); - *encrypted.last_mut().unwrap() ^= 0xff; - let source: OpenSecretResponseBody = Box::pin(futures::stream::iter([Ok(Bytes::from( - format!("data: {}\n\n", BASE64.encode(encrypted)), - ))])); - - let error = collect_response_body(decrypt_sse_stream(source, session_key)) - .await - .unwrap_err(); - - assert!(matches!(error, Error::Decryption(message) if message.contains("SSE data"))); - } - - #[tokio::test] - async fn inference_sse_transport_bounds_each_line_not_each_network_chunk() { - let session_key = [37u8; 32]; - let within_limit = Bytes::from_static(b":1\n:2\n:3\n"); - let source: OpenSecretResponseBody = - Box::pin(futures::stream::iter([Ok(within_limit.clone())])); - let actual = - collect_response_body(decrypt_sse_stream_with_line_limit(source, session_key, 8)) - .await - .unwrap(); - assert_eq!(actual, within_limit); - - let exact_limit = Bytes::from_static(b":1234567"); - let source: OpenSecretResponseBody = - Box::pin(futures::stream::iter([Ok(exact_limit.clone())])); - let actual = - collect_response_body(decrypt_sse_stream_with_line_limit(source, session_key, 8)) - .await - .unwrap(); - assert_eq!(actual, exact_limit); - - for chunks in [ - vec![ - Ok(Bytes::from_static(b":1234")), - Ok(Bytes::from_static(b"5678")), - ], - vec![Ok(Bytes::from_static(b":12345678\n"))], - ] { - let source: OpenSecretResponseBody = Box::pin(futures::stream::iter(chunks)); - let error = - collect_response_body(decrypt_sse_stream_with_line_limit(source, session_key, 8)) - .await - .unwrap_err(); - assert!(matches!( - error, - Error::InvalidResponse(message) if message.contains("8-byte limit") - )); - } - } - - #[tokio::test] - async fn typed_chat_completion_keeps_legacy_stream_false_and_error_mapping() { - let mock_server = MockServer::start().await; - let client = - OpenSecretClient::new_with_api_key(mock_server.uri(), "api_key".to_string()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [32u8; 32]; - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - - Mock::given(method("POST")) - .and(path("/v1/chat/completions")) - .and(EncryptedJsonBodyMatcher { - session_key, - expected: json!({ - "model": "typed-model", - "messages": [{"role": "user", "content": "hi"}], - "stream": false - }), - }) - .respond_with(ResponseTemplate::new(409).set_body_string("typed conflict")) - .expect(1) - .mount(&mock_server) - .await; - - let error = client - .create_chat_completion(ChatCompletionRequest { - model: "typed-model".to_string(), - messages: vec![ChatMessage { - role: "user".to_string(), - content: json!("hi"), - tool_calls: None, - reasoning_content: None, - }], - temperature: None, - max_tokens: None, - stream: Some(true), - stream_options: None, - tools: None, - tool_choice: None, - }) - .await - .unwrap_err(); - - assert!(matches!( - error, - Error::Api { status: 409, message } if message == "typed conflict" - )); - } - - #[tokio::test] - async fn typed_models_and_embeddings_remain_compatible() { - let mock_server = MockServer::start().await; - let client = - OpenSecretClient::new_with_api_key(mock_server.uri(), "api_key".to_string()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [33u8; 32]; - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - - Mock::given(method("GET")) - .and(path("/v1/models")) - .respond_with(ResponseTemplate::new(200).set_body_json(encrypted_response( - &session_key, - &json!({ - "object": "list", - "data": [{"id": "model-a", "object": "model"}] - }), - ))) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/v1/embeddings")) - .and(EncryptedJsonBodyMatcher { - session_key, - expected: json!({ - "input": "hello", - "model": "embedding-model" - }), - }) - .respond_with(ResponseTemplate::new(200).set_body_json(encrypted_response( - &session_key, - &json!({ - "object": "list", - "data": [{"object": "embedding", "index": 0, "embedding": [0.25, 0.5]}], - "model": "embedding-model", - "usage": {"prompt_tokens": 1, "total_tokens": 1} - }), - ))) - .expect(1) - .mount(&mock_server) - .await; - - let models = client.get_models().await.unwrap(); - let embeddings = client - .create_embeddings(EmbeddingRequest { - input: "hello".into(), - model: "embedding-model".to_string(), - encoding_format: None, - dimensions: None, - user: None, - }) - .await - .unwrap(); - - assert_eq!(models.data[0].id, "model-a"); - assert_eq!( - embeddings.data[0].embedding.as_floats(), - Some(&[0.25, 0.5][..]) - ); - } - - #[tokio::test] - async fn test_refresh_reestablishes_attestation_without_sending_auth_headers() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let server_secret_key = [11u8; 32]; - let server_public_key = - x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(server_secret_key)); - let session_key = [9u8; 32]; - let session_id = Uuid::new_v4().to_string(); - let refreshed_access = "refreshed_access"; - let refreshed_refresh = "refreshed_refresh"; - - client - .session_manager - .set_tokens( - "expired_access".to_string(), - Some("refresh_token".to_string()), - ) - .unwrap(); - - Mock::given(method("GET")) - .and(PathPrefixMatcher("/attestation/")) - .respond_with(AttestationResponder { - server_public_key: server_public_key.to_bytes(), - }) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("POST")) - .and(path("/key_exchange")) - .and(MissingHeaderMatcher("authorization")) - .respond_with(KeyExchangeResponder { - server_secret_key, - session_key, - session_id: session_id.clone(), - }) - .expect(1) - .mount(&mock_server) - .await; - - Mock::given(method("POST")) - .and(path("/refresh")) - .and(MissingHeaderMatcher("authorization")) - .and(header("x-session-id", session_id.clone())) - .respond_with(ResponseTemplate::new(200).set_body_json(encrypted_response( - &session_key, - &json!({ - "access_token": refreshed_access, - "refresh_token": refreshed_refresh, - }), - ))) - .expect(1) - .mount(&mock_server) - .await; - - client.refresh_token().await.unwrap(); - - assert_eq!( - client.get_session_id().unwrap(), - Some(Uuid::parse_str(&session_id).unwrap()) - ); - assert_eq!( - client.get_access_token().unwrap().as_deref(), - Some(refreshed_access) - ); - assert_eq!( - client.get_refresh_token().unwrap().as_deref(), - Some(refreshed_refresh) - ); - } - - #[tokio::test] - async fn delayed_manual_refresh_does_not_overwrite_newly_set_tokens() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [43u8; 32]; - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens("old_access".to_string(), Some("old_refresh".to_string())) - .unwrap(); - - Mock::given(method("POST")) - .and(path("/refresh")) - .and(EncryptedJsonBodyMatcher { - session_key, - expected: json!({ "refresh_token": "old_refresh" }), - }) - .respond_with( - ResponseTemplate::new(200) - .set_delay(Duration::from_millis(100)) - .set_body_json(encrypted_response( - &session_key, - &json!({ - "access_token": "stale_refreshed_access", - "refresh_token": "stale_refreshed_refresh", - }), - )), - ) - .expect(1) - .mount(&mock_server) - .await; - - let (refresh_result, ()) = tokio::join!(client.refresh_token(), async { - tokio::time::sleep(Duration::from_millis(25)).await; - client - .set_tokens("app_access".to_string(), Some("app_refresh".to_string())) - .unwrap(); - }); - - refresh_result.unwrap(); - assert_eq!( - client.get_access_token().unwrap().as_deref(), - Some("app_access") - ); - assert_eq!( - client.get_refresh_token().unwrap().as_deref(), - Some("app_refresh") - ); - assert!(client.get_session_id().unwrap().is_none()); - } - - #[tokio::test] - async fn delayed_manual_refresh_does_not_restore_cleared_credentials() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [44u8; 32]; - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens("old_access".to_string(), Some("old_refresh".to_string())) - .unwrap(); - - Mock::given(method("POST")) - .and(path("/refresh")) - .respond_with( - ResponseTemplate::new(200) - .set_delay(Duration::from_millis(100)) - .set_body_json(encrypted_response( - &session_key, - &json!({ - "access_token": "stale_refreshed_access", - "refresh_token": "stale_refreshed_refresh", - }), - )), - ) - .expect(1) - .mount(&mock_server) - .await; - - let (refresh_result, clear_result) = tokio::join!(client.refresh_token(), async { - tokio::time::sleep(Duration::from_millis(25)).await; - client.session_manager.clear_all() - }); - - refresh_result.unwrap(); - clear_result.unwrap(); - assert!(client.get_access_token().unwrap().is_none()); - assert!(client.get_refresh_token().unwrap().is_none()); - assert!(client.get_session_id().unwrap().is_none()); - } - - #[tokio::test] - async fn concurrent_manual_refresh_and_logout_finish_logged_out() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [45u8; 32]; - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens("old_access".to_string(), Some("old_refresh".to_string())) - .unwrap(); - - Mock::given(method("POST")) - .and(path("/refresh")) - .respond_with( - ResponseTemplate::new(200) - .set_delay(Duration::from_millis(100)) - .set_body_json(encrypted_response( - &session_key, - &json!({ - "access_token": "fresh_access", - "refresh_token": "fresh_refresh", - }), - )), - ) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/logout")) - .and(EncryptedJsonBodyMatcher { - session_key, - expected: json!({ - "refresh_token": "fresh_refresh" - }), - }) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(encrypted_response(&session_key, &json!({}))), - ) - .expect(1) - .mount(&mock_server) - .await; - - let (refresh_result, logout_result) = tokio::join!(client.refresh_token(), async { - tokio::time::sleep(Duration::from_millis(25)).await; - client.logout().await - }); - - refresh_result.unwrap(); - logout_result.unwrap(); - assert!(client.get_access_token().unwrap().is_none()); - assert!(client.get_refresh_token().unwrap().is_none()); - assert!(client.get_session_id().unwrap().is_none()); - } - - #[tokio::test] - async fn delayed_logout_does_not_clear_newly_set_tokens() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [49u8; 32]; - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens("old_access".to_string(), Some("old_refresh".to_string())) - .unwrap(); - - Mock::given(method("POST")) - .and(path("/logout")) - .and(EncryptedJsonBodyMatcher { - session_key, - expected: json!({ - "refresh_token": "old_refresh" - }), - }) - .respond_with( - ResponseTemplate::new(200) - .set_delay(Duration::from_millis(100)) - .set_body_json(encrypted_response(&session_key, &json!({}))), - ) - .expect(1) - .mount(&mock_server) - .await; - - let (logout_result, ()) = tokio::join!(client.logout(), async { - tokio::time::sleep(Duration::from_millis(25)).await; - client - .set_tokens("new_access".to_string(), Some("new_refresh".to_string())) - .unwrap(); - }); - - logout_result.unwrap(); - assert_eq!( - client.get_access_token().unwrap().as_deref(), - Some("new_access") - ); - assert_eq!( - client.get_refresh_token().unwrap().as_deref(), - Some("new_refresh") - ); - assert!(client.get_session_id().unwrap().is_none()); - } - - #[tokio::test] - async fn web_search_uses_authenticated_encrypted_endpoint() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [51u8; 32]; - - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "web_access_token".to_string(), - Some("web_refresh_token".to_string()), - ) - .unwrap(); - - let request = WebSearchRequest { - query: "rust confidential computing".to_string(), - workflow: Some(WebSearchWorkflow::News), - page: Some(2), - limit: Some(25), - safe_search: Some(false), - timeout: Some(2.5), - lens_id: None, - lens: Some(WebSearchLens { - sites_included: Some(vec!["example.com".to_string()]), - keywords_included: Some(vec!["enclave".to_string()]), - time_relative: Some(WebSearchTimeRelative::Week), - search_region: Some("US".to_string()), - ..Default::default() - }), - filters: Some(WebSearchFilters { - region: Some("US".to_string()), - after: None, - before: None, - }), - }; - let response = WebSearchResponse { - trace_id: Some("trace-search-1".to_string()), - results: vec![WebSearchResult { - category: "news".to_string(), - url: "https://example.com/enclave".to_string(), - title: "Enclave update".to_string(), - snippet: Some("A short description.".to_string()), - published_at: Some("2026-07-16T12:00:00Z".to_string()), - }], - }; - - Mock::given(method("POST")) - .and(path("/v1/web/search")) - .and(header("authorization", "Bearer web_access_token")) - .and(header("x-session-id", session_id.to_string())) - .and(EncryptedJsonBodyMatcher { - session_key, - expected: json!({ - "query": "rust confidential computing", - "workflow": "news", - "page": 2, - "limit": 25, - "safe_search": false, - "timeout": 2.5, - "lens": { - "sites_included": ["example.com"], - "keywords_included": ["enclave"], - "time_relative": "week", - "search_region": "US" - }, - "filters": { - "region": "US" - } - }), - }) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(encrypted_response(&session_key, &response)), - ) - .expect(1) - .mount(&mock_server) - .await; - - let actual = client.web_search(request).await.unwrap(); - - assert_eq!(actual, response); - mock_server.verify().await; - } - - #[tokio::test] - async fn web_extract_preserves_order_and_partial_page_errors() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [52u8; 32]; - - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "web_access_token".to_string(), - Some("web_refresh_token".to_string()), - ) - .unwrap(); - - let first_url = "https://example.com/first".to_string(); - let second_url = "https://example.com/second".to_string(); - let request = WebExtractRequest { - urls: vec![first_url.clone(), second_url.clone()], - timeout: Some(4.5), - }; - let response = WebExtractResponse { - trace_id: Some("trace-extract-1".to_string()), - pages: vec![ - WebExtractPage { - url: first_url.clone(), - markdown: Some("# First\n\nExtracted text.".to_string()), - error: None, - }, - WebExtractPage { - url: second_url.clone(), - markdown: None, - error: Some(WebExtractPageError { - code: "no_content".to_string(), - message: "No readable content was found.".to_string(), - }), - }, - ], - }; - - Mock::given(method("POST")) - .and(path("/v1/web/extract")) - .and(header("authorization", "Bearer web_access_token")) - .and(header("x-session-id", session_id.to_string())) - .and(EncryptedJsonBodyMatcher { - session_key, - expected: json!({ - "urls": [first_url, second_url], - "timeout": 4.5 - }), - }) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(encrypted_response(&session_key, &response)), - ) - .expect(1) - .mount(&mock_server) - .await; - - let actual = client.web_extract(request).await.unwrap(); - - assert_eq!(actual, response); - assert_eq!(actual.pages[0].url, "https://example.com/first"); - assert_eq!( - actual.pages[1] - .error - .as_ref() - .map(|error| error.code.as_str()), - Some("no_content") - ); - mock_server.verify().await; - } - - #[tokio::test] - async fn web_validation_error_does_not_retry_attestation() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [53u8; 32]; - - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "web_access_token".to_string(), - Some("web_refresh_token".to_string()), - ) - .unwrap(); - - Mock::given(method("POST")) - .and(path("/v1/web/search")) - .and(header("authorization", "Bearer web_access_token")) - .and(header("x-session-id", session_id.to_string())) - .respond_with(ResponseTemplate::new(422).set_body_json(json!({ - "status": 422, - "code": "invalid_request", - "message": "The web request is invalid." - }))) - .expect(1) - .mount(&mock_server) - .await; - - let error = client - .web_search(WebSearchRequest::new("maple privacy")) - .await - .unwrap_err(); - - match error { - Error::Api { status, message } => { - assert_eq!(status, 422); - assert!(message.contains("invalid_request")); - } - other => panic!("expected API validation error, got {other:?}"), - } - mock_server.verify().await; - } - - #[tokio::test] - async fn test_init_main_agent_uses_authenticated_encrypted_v1_endpoint() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [31u8; 32]; - - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "access_token".to_string(), - Some("refresh_token".to_string()), - ) - .unwrap(); - - let request = InitMainAgentRequest { - timezone: Some("America/Chicago".to_string()), - locale: Some("en-US".to_string()), - }; - let response = InitMainAgentResponse { - id: Uuid::new_v4(), - object: "agent.main".to_string(), - kind: "main".to_string(), - conversation_id: Uuid::new_v4(), - display_name: "Maple".to_string(), - created_at: 1_710_000_000, - updated_at: 1_710_000_000, - messages: vec![ConversationItem::Message { - id: Uuid::new_v4(), - status: Some("completed".to_string()), - role: "assistant".to_string(), - content: vec![ConversationContent::OutputText { - text: "Hey — I'm Maple.".to_string(), - }], - reaction: None, - created_at: Some(1_710_000_000), - }], - }; - let expected_request = request.clone(); - let expected_response = response.clone(); - - Mock::given(method("POST")) - .and(path("/v1/agent/init")) - .and(header("authorization", "Bearer access_token")) - .and(header("x-session-id", session_id.to_string())) - .respond_with(move |req: &Request| { - let body: InitMainAgentRequest = decrypt_request_body(req, &session_key); - assert_eq!(body, expected_request); - - ResponseTemplate::new(200) - .set_body_json(encrypted_response(&session_key, &expected_response)) - }) - .expect(1) - .mount(&mock_server) - .await; - - let initialized = client.init_main_agent(request.clone()).await.unwrap(); - - assert_eq!(initialized.id, response.id); - assert_eq!(initialized.conversation_id, response.conversation_id); - assert_eq!(initialized.display_name, "Maple"); - assert_eq!(initialized.messages.len(), 1); - } - - #[tokio::test] - async fn test_set_main_agent_item_reaction_uses_authenticated_encrypted_v1_endpoint() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [32u8; 32]; - let item_id = Uuid::new_v4(); - - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "access_token".to_string(), - Some("refresh_token".to_string()), - ) - .unwrap(); - - let response = ConversationItem::Message { - id: item_id, - status: Some("completed".to_string()), - role: "assistant".to_string(), - content: vec![ConversationContent::OutputText { - text: "Nice!".to_string(), - }], - reaction: Some("🎉".to_string()), - created_at: Some(1_710_000_000), - }; - let expected_response = response.clone(); - - Mock::given(method("POST")) - .and(path(format!("/v1/agent/items/{}/reaction", item_id))) - .and(header("authorization", "Bearer access_token")) - .and(header("x-session-id", session_id.to_string())) - .respond_with(move |req: &Request| { - let body: SetMessageReactionRequest = decrypt_request_body(req, &session_key); - assert_eq!( - body, - SetMessageReactionRequest { - emoji: "🎉".to_string() - } - ); - - ResponseTemplate::new(200) - .set_body_json(encrypted_response(&session_key, &expected_response)) - }) - .expect(1) - .mount(&mock_server) - .await; - - let item = client - .set_main_agent_item_reaction(item_id, "🎉") - .await - .unwrap(); - - match item { - ConversationItem::Message { id, reaction, .. } => { - assert_eq!(id, item_id); - assert_eq!(reaction.as_deref(), Some("🎉")); - } - other => panic!("Expected message item, got {:?}", other), - } - } - - #[tokio::test] - async fn test_clear_subagent_item_reaction_uses_authenticated_v1_endpoint() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [33u8; 32]; - let subagent_id = Uuid::new_v4(); - let item_id = Uuid::new_v4(); - - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "access_token".to_string(), - Some("refresh_token".to_string()), - ) - .unwrap(); - - let response = ConversationItem::Message { - id: item_id, - status: Some("completed".to_string()), - role: "assistant".to_string(), - content: vec![ConversationContent::OutputText { - text: "Done".to_string(), - }], - reaction: None, - created_at: Some(1_710_000_001), - }; - let expected_response = response.clone(); - - Mock::given(method("DELETE")) - .and(path(format!( - "/v1/agent/subagents/{}/items/{}/reaction", - subagent_id, item_id - ))) - .and(header("authorization", "Bearer access_token")) - .and(header("x-session-id", session_id.to_string())) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(encrypted_response(&session_key, &expected_response)), - ) - .expect(1) - .mount(&mock_server) - .await; - - let item = client - .clear_subagent_item_reaction(subagent_id, item_id) - .await - .unwrap(); - - match item { - ConversationItem::Message { id, reaction, .. } => { - assert_eq!(id, item_id); - assert_eq!(reaction, None); - } - other => panic!("Expected message item, got {:?}", other), - } - } - - #[tokio::test] - async fn test_agent_chat_stream_parses_reaction_and_message_ids() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [34u8; 32]; - let reaction_item_id = Uuid::new_v4(); - let message_id = Uuid::new_v4(); - - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "access_token".to_string(), - Some("refresh_token".to_string()), - ) - .unwrap(); - - let sse_body = format!( - "{}{}{}{}data: [DONE]\n\n", - encrypted_sse_data(&session_key, &json!({})).replacen( - "data:", - "event: agent.typing\ndata:", - 1 - ), - encrypted_sse_data( - &session_key, - &json!({ - "item_id": reaction_item_id, - "emoji": "🫡" - }) - ) - .replacen("data:", "event: agent.reaction\ndata:", 1), - encrypted_sse_data( - &session_key, - &json!({ - "message_id": message_id, - "message": "hello there" - }) - ) - .replacen("data:", "event: agent.message\ndata:", 1), - encrypted_sse_data(&session_key, &json!({})).replacen( - "data:", - "event: agent.done\ndata:", - 1 - ), - ); - - Mock::given(method("POST")) - .and(path("/v1/agent/chat")) - .and(header("authorization", "Bearer access_token")) - .and(header("x-session-id", session_id.to_string())) - .respond_with(move |req: &Request| { - let body: AgentChatRequest = decrypt_request_body(req, &session_key); - assert_eq!(body.input, "hey there"); - - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.clone()) - }) - .expect(1) - .mount(&mock_server) - .await; - - let mut stream = client.agent_chat("hey there").await.unwrap(); - - match stream.next().await.unwrap().unwrap() { - AgentSseEvent::Typing(_) => {} - other => panic!("Expected typing event, got {:?}", other), - } - - match stream.next().await.unwrap().unwrap() { - AgentSseEvent::Reaction(event) => { - assert_eq!(event.item_id, reaction_item_id); - assert_eq!(event.emoji, "🫡"); - } - other => panic!("Expected reaction event, got {:?}", other), - } - - match stream.next().await.unwrap().unwrap() { - AgentSseEvent::Message(event) => { - assert_eq!(event.message_id, message_id); - assert_eq!(event.message, "hello there".to_string()); - } - other => panic!("Expected message event, got {:?}", other), - } - - match stream.next().await.unwrap().unwrap() { - AgentSseEvent::Done(_) => {} - other => panic!("Expected done event, got {:?}", other), - } - - assert!(stream.next().await.is_none()); - } - - #[tokio::test] - async fn agent_stream_v1_generic_401_does_not_refresh() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [49u8; 32]; - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "access_token".to_string(), - Some("refresh_token".to_string()), - ) - .unwrap(); - - Mock::given(method("POST")) - .and(path("/v1/agent/chat")) - .and(header("authorization", "Bearer access_token")) - .and(header("x-session-id", session_id.to_string())) - .and(EncryptedJsonBodyMatcher { - session_key, - expected: json!({ "input": "generic unauthorized" }), - }) - .respond_with(v1_error_response(401, None, "generic unauthorized")) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/refresh")) - .respond_with(ResponseTemplate::new(500)) - .expect(0) - .mount(&mock_server) - .await; - - match client.agent_chat("generic unauthorized").await { - Err(Error::Api { status: 401, .. }) => {} - Err(other) => panic!("expected generic 401 API error, got {other:?}"), - Ok(_) => panic!("generic 401 unexpectedly started an Agent stream"), - } - mock_server.verify().await; - } - - #[tokio::test] - async fn concurrent_agent_stream_401s_share_one_refresh_and_decrypt() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let session_id = Uuid::new_v4(); - let session_key = [50u8; 32]; - let message_id = Uuid::new_v4(); - client - .session_manager - .set_session(session_id, session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "expired_access".to_string(), - Some("refresh_token".to_string()), - ) - .unwrap(); - - Mock::given(method("POST")) - .and(path("/v1/agent/chat")) - .and(header("authorization", "Bearer expired_access")) - .and(header("x-session-id", session_id.to_string())) - .and(EncryptedJsonBodyMatcher { - session_key, - expected: json!({ "input": "stream retry" }), - }) - .respond_with( - ResponseTemplate::new(401) - .insert_header(ERROR_CONTRACT_HEADER, "1") - .insert_header(ERROR_CODE_HEADER, "access_token_expired") - .set_delay(Duration::from_millis(50)) - .set_body_string("jwt expired"), - ) - .expect(2) - .mount(&mock_server) - .await; - - Mock::given(method("POST")) - .and(path("/refresh")) - .and(MissingHeaderMatcher("authorization")) - .and(header("x-session-id", session_id.to_string())) - .and(EncryptedJsonBodyMatcher { - session_key, - expected: json!({ "refresh_token": "refresh_token" }), - }) - .respond_with( - ResponseTemplate::new(200) - .set_delay(Duration::from_millis(100)) - .set_body_json(encrypted_response( - &session_key, - &json!({ - "access_token": "fresh_access", - "refresh_token": "fresh_refresh", - }), - )), - ) - .expect(1) - .mount(&mock_server) - .await; - - let sse_body = format!( - "{}data: [DONE]\n\n", - encrypted_sse_data( - &session_key, - &json!({ - "message_id": message_id, - "message": "stream recovered" - }) - ) - .replacen("data:", "event: agent.message\ndata:", 1), - ); - Mock::given(method("POST")) - .and(path("/v1/agent/chat")) - .and(header("authorization", "Bearer fresh_access")) - .and(header("x-session-id", session_id.to_string())) - .and(EncryptedJsonBodyMatcher { - session_key, - expected: json!({ "input": "stream retry" }), - }) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body), - ) - .expect(2) - .mount(&mock_server) - .await; - - let (first, second) = tokio::join!( - client.agent_chat("stream retry"), - client.agent_chat("stream retry") - ); - let mut first = first.unwrap(); - let mut second = second.unwrap(); - - for stream in [&mut first, &mut second] { - match stream.next().await.unwrap().unwrap() { - AgentSseEvent::Message(event) => { - assert_eq!(event.message_id, message_id); - assert_eq!(event.message, "stream recovered"); - } - other => panic!("Expected recovered agent message, got {other:?}"), - } - assert!(stream.next().await.is_none()); - } - assert_eq!( - client.get_access_token().unwrap().as_deref(), - Some("fresh_access") - ); - assert_eq!( - client.get_refresh_token().unwrap().as_deref(), - Some("fresh_refresh") - ); - mock_server.verify().await; - } - - #[tokio::test] - async fn agent_stream_stale_session_reattests_and_decrypts() { - let mock_server = MockServer::start().await; - let client = OpenSecretClient::new(mock_server.uri()).unwrap(); - let stale_session_id = Uuid::new_v4(); - let stale_session_key = [51u8; 32]; - let server_secret_key = [52u8; 32]; - let server_public_key = - x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(server_secret_key)); - let fresh_session_id = Uuid::new_v4(); - let fresh_session_key = [53u8; 32]; - let message_id = Uuid::new_v4(); - client - .session_manager - .set_session(stale_session_id, stale_session_key) - .unwrap(); - client - .session_manager - .set_tokens( - "access_token".to_string(), - Some("refresh_token".to_string()), - ) - .unwrap(); - - Mock::given(method("POST")) - .and(path("/v1/agent/chat")) - .and(header("authorization", "Bearer access_token")) - .and(header("x-session-id", stale_session_id.to_string())) - .and(EncryptedJsonBodyMatcher { - session_key: stale_session_key, - expected: json!({ "input": "stale stream" }), - }) - .respond_with(ResponseTemplate::new(400).set_body_string("stale session")) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("GET")) - .and(PathPrefixMatcher("/attestation/")) - .respond_with(AttestationResponder { - server_public_key: server_public_key.to_bytes(), - }) - .expect(1) - .mount(&mock_server) - .await; - Mock::given(method("POST")) - .and(path("/key_exchange")) - .and(MissingHeaderMatcher("authorization")) - .respond_with(KeyExchangeResponder { - server_secret_key, - session_key: fresh_session_key, - session_id: fresh_session_id.to_string(), - }) - .expect(1) - .mount(&mock_server) - .await; - - let sse_body = format!( - "{}data: [DONE]\n\n", - encrypted_sse_data( - &fresh_session_key, - &json!({ - "message_id": message_id, - "message": "fresh session" - }) - ) - .replacen("data:", "event: agent.message\ndata:", 1), - ); - Mock::given(method("POST")) - .and(path("/v1/agent/chat")) - .and(header("authorization", "Bearer access_token")) - .and(header("x-session-id", fresh_session_id.to_string())) - .and(EncryptedJsonBodyMatcher { - session_key: fresh_session_key, - expected: json!({ "input": "stale stream" }), - }) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body), - ) - .expect(1) - .mount(&mock_server) - .await; + .get_credential_snapshot() + .unwrap() + .generation; + client.set_api_key("api-key-b".to_string()).unwrap(); - let mut stream = client.agent_chat("stale stream").await.unwrap(); - match stream.next().await.unwrap().unwrap() { - AgentSseEvent::Message(event) => { - assert_eq!(event.message_id, message_id); - assert_eq!(event.message, "fresh session"); - } - other => panic!("Expected agent message after re-attestation, got {other:?}"), - } - assert!(stream.next().await.is_none()); - assert_eq!(client.get_session_id().unwrap(), Some(fresh_session_id)); - mock_server.verify().await; + assert!(matches!( + client.ensure_credential_generation(generation), + Err(Error::Session(_)) + )); } } diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 9fb8fa06b..81f5952ed 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -9,7 +9,10 @@ pub mod session; mod transport_v2; pub mod types; -pub use client::{InferenceRequest, InferenceResponse, OpenSecretClient, OpenSecretResponseBody}; +pub use client::{ + InferenceRequest, InferenceResponse, NativeOAuthHandoffGrant, NativeOAuthSessionInfo, + OpenSecretClient, OpenSecretResponseBody, TransportV2CacheNamespaceRoot, +}; pub use error::{Error, Result}; pub use pcr::{Pcr0Environment, Pcr0TrustPolicy}; pub use push::*; diff --git a/sdk/rust/src/session.rs b/sdk/rust/src/session.rs index 7e032a4dd..a4051bb60 100644 --- a/sdk/rust/src/session.rs +++ b/sdk/rust/src/session.rs @@ -1,26 +1,35 @@ use crate::error::{Error, Result}; +use crate::transport_v2::V2Session; use crate::types::{SessionState, TokenPair}; use std::sync::{Arc, RwLock}; use uuid::Uuid; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Eq, PartialEq)] +pub(crate) struct UserAuthEpoch { + pub(crate) generation: u64, + pub(crate) principal: Option, +} + +#[derive(Clone)] pub(crate) struct CredentialSnapshot { pub(crate) tokens: Option, pub(crate) api_key: Option, pub(crate) generation: u64, - pub(crate) token_generation: u64, - pub(crate) api_key_generation: u64, + pub(crate) auth_epoch: UserAuthEpoch, + pub(crate) user_session: Option>, } -#[derive(Debug, Default)] +#[derive(Default)] struct CredentialState { tokens: Option, + token_principal: Option, + user_session: Option>, api_key: Option, generation: u64, token_generation: u64, - api_key_generation: u64, } +#[derive(Clone)] pub struct SessionManager { session: Arc>>, credentials: Arc>, @@ -40,7 +49,6 @@ impl SessionManager { credentials: Arc::new(RwLock::new(CredentialState { api_key: Some(api_key), generation: 1, - api_key_generation: 1, ..CredentialState::default() })), } @@ -53,7 +61,6 @@ impl SessionManager { credentials.api_key = Some(api_key); credentials.generation = credentials.generation.wrapping_add(1); - credentials.api_key_generation = credentials.api_key_generation.wrapping_add(1); Ok(()) } @@ -72,7 +79,6 @@ impl SessionManager { credentials.api_key = None; credentials.generation = credentials.generation.wrapping_add(1); - credentials.api_key_generation = credentials.api_key_generation.wrapping_add(1); Ok(()) } @@ -110,6 +116,16 @@ impl SessionManager { } pub fn set_tokens(&self, access_token: String, refresh_token: Option) -> Result<()> { + self.replace_user_tokens(access_token, refresh_token, None) + .map(|_| ()) + } + + pub(crate) fn replace_user_tokens( + &self, + access_token: String, + refresh_token: Option, + principal: Option, + ) -> Result { let mut credentials = self.credentials.write().map_err(|e| { Error::Authentication(format!("Failed to acquire credentials write lock: {}", e)) })?; @@ -118,33 +134,65 @@ impl SessionManager { access_token, refresh_token, }); + credentials.token_principal = principal; + credentials.user_session = None; credentials.generation = credentials.generation.wrapping_add(1); credentials.token_generation = credentials.token_generation.wrapping_add(1); - Ok(()) + Ok(user_auth_epoch(&credentials)) } - pub(crate) fn set_tokens_if_generation( + pub(crate) fn replace_user_tokens_and_session_if_epoch( &self, - expected_token_generation: u64, + expected: &UserAuthEpoch, access_token: String, refresh_token: Option, - ) -> Result { + principal: String, + session: Arc, + ) -> Result> { let mut credentials = self.credentials.write().map_err(|e| { Error::Authentication(format!("Failed to acquire credentials write lock: {}", e)) })?; - if credentials.token_generation != expected_token_generation { - return Ok(false); + if user_auth_epoch(&credentials) != *expected { + return Ok(None); } credentials.tokens = Some(TokenPair { access_token, refresh_token, }); + credentials.token_principal = Some(principal); + credentials.user_session = Some(session); credentials.generation = credentials.generation.wrapping_add(1); credentials.token_generation = credentials.token_generation.wrapping_add(1); - Ok(true) + Ok(Some(user_auth_epoch(&credentials))) + } + + pub(crate) fn replace_user_tokens_if_epoch( + &self, + expected: &UserAuthEpoch, + access_token: String, + refresh_token: Option, + principal: String, + ) -> Result> { + let mut credentials = self.credentials.write().map_err(|e| { + Error::Authentication(format!("Failed to acquire credentials write lock: {}", e)) + })?; + + if user_auth_epoch(&credentials) != *expected { + return Ok(None); + } + + credentials.tokens = Some(TokenPair { + access_token, + refresh_token, + }); + credentials.token_principal = Some(principal); + credentials.user_session = None; + credentials.generation = credentials.generation.wrapping_add(1); + credentials.token_generation = credentials.token_generation.wrapping_add(1); + Ok(Some(user_auth_epoch(&credentials))) } pub(crate) fn get_credential_snapshot(&self) -> Result { @@ -152,13 +200,58 @@ impl SessionManager { Error::Authentication(format!("Failed to acquire credentials read lock: {}", e)) })?; - Ok(CredentialSnapshot { - tokens: credentials.tokens.clone(), - api_key: credentials.api_key.clone(), - generation: credentials.generation, - token_generation: credentials.token_generation, - api_key_generation: credentials.api_key_generation, - }) + Ok(credential_snapshot(&credentials)) + } + + pub(crate) fn get_credential_snapshot_if_auth_epoch( + &self, + expected: &UserAuthEpoch, + ) -> Result> { + let credentials = self.credentials.read().map_err(|e| { + Error::Authentication(format!("Failed to acquire credentials read lock: {}", e)) + })?; + + if user_auth_epoch(&credentials) != *expected { + return Ok(None); + } + + Ok(Some(credential_snapshot(&credentials))) + } + + pub(crate) fn credential_generation_matches(&self, expected: u64) -> Result { + let credentials = self.credentials.read().map_err(|e| { + Error::Authentication(format!("Failed to acquire credentials read lock: {}", e)) + })?; + Ok(credentials.generation == expected) + } + + pub(crate) fn get_user_session(&self) -> Result>> { + let credentials = self.credentials.read().map_err(|e| { + Error::Authentication(format!("Failed to acquire credentials read lock: {}", e)) + })?; + Ok(credentials.user_session.clone()) + } + + pub(crate) fn clear_user_session(&self) -> Result<()> { + let mut credentials = self.credentials.write().map_err(|e| { + Error::Authentication(format!("Failed to acquire credentials write lock: {}", e)) + })?; + credentials.user_session = None; + Ok(()) + } + + pub(crate) fn clear_user_session_if(&self, expected: &Arc) -> Result<()> { + let mut credentials = self.credentials.write().map_err(|e| { + Error::Authentication(format!("Failed to acquire credentials write lock: {}", e)) + })?; + if credentials + .user_session + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, expected)) + { + credentials.user_session = None; + } + Ok(()) } pub fn get_tokens(&self) -> Result> { @@ -204,6 +297,8 @@ impl SessionManager { credentials.generation = credentials.generation.wrapping_add(1); credentials.token_generation = credentials.token_generation.wrapping_add(1); + credentials.token_principal = None; + credentials.user_session = None; Ok(()) } @@ -213,21 +308,39 @@ impl SessionManager { })?; credentials.tokens = None; + credentials.token_principal = None; + credentials.user_session = None; credentials.generation = credentials.generation.wrapping_add(1); credentials.token_generation = credentials.token_generation.wrapping_add(1); Ok(()) } + pub(crate) fn invalidate_user_auth_if_epoch(&self, expected: &UserAuthEpoch) -> Result { + let mut credentials = self.credentials.write().map_err(|e| { + Error::Authentication(format!("Failed to acquire credentials write lock: {}", e)) + })?; + if user_auth_epoch(&credentials) != *expected { + return Ok(false); + } + credentials.tokens = None; + credentials.token_principal = None; + credentials.user_session = None; + credentials.generation = credentials.generation.wrapping_add(1); + credentials.token_generation = credentials.token_generation.wrapping_add(1); + Ok(true) + } + pub fn clear_all(&self) -> Result<()> { self.clear_session()?; let mut credentials = self.credentials.write().map_err(|e| { Error::Authentication(format!("Failed to acquire credentials write lock: {}", e)) })?; credentials.tokens = None; + credentials.token_principal = None; + credentials.user_session = None; credentials.api_key = None; credentials.generation = credentials.generation.wrapping_add(1); credentials.token_generation = credentials.token_generation.wrapping_add(1); - credentials.api_key_generation = credentials.api_key_generation.wrapping_add(1); Ok(()) } @@ -242,14 +355,32 @@ impl SessionManager { self.clear_session()?; credentials.tokens = None; + credentials.token_principal = None; + credentials.user_session = None; credentials.api_key = None; credentials.generation = credentials.generation.wrapping_add(1); credentials.token_generation = credentials.token_generation.wrapping_add(1); - credentials.api_key_generation = credentials.api_key_generation.wrapping_add(1); Ok(true) } } +fn credential_snapshot(credentials: &CredentialState) -> CredentialSnapshot { + CredentialSnapshot { + tokens: credentials.tokens.clone(), + api_key: credentials.api_key.clone(), + generation: credentials.generation, + auth_epoch: user_auth_epoch(credentials), + user_session: credentials.user_session.clone(), + } +} + +fn user_auth_epoch(credentials: &CredentialState) -> UserAuthEpoch { + UserAuthEpoch { + generation: credentials.token_generation, + principal: credentials.token_principal.clone(), + } +} + impl Default for SessionManager { fn default() -> Self { Self::new() @@ -260,6 +391,13 @@ impl Default for SessionManager { mod tests { use super::*; + fn v2_session(marker: u8) -> Arc { + Arc::new( + V2Session::from_master_for_test(Uuid::from_bytes([marker; 16]), [marker; 32], u64::MAX) + .expect("test v2 session"), + ) + } + #[test] fn test_session_management() { let manager = SessionManager::new(); @@ -312,4 +450,88 @@ mod tests { manager.clear_tokens().unwrap(); assert!(manager.get_tokens().unwrap().is_none()); } + + #[test] + fn stale_auth_commit_cannot_overwrite_or_clear_a_new_principal() { + let manager = SessionManager::new(); + let initial = manager.get_credential_snapshot().unwrap().auth_epoch; + let old_session = v2_session(0x11); + let old_epoch = manager + .replace_user_tokens_and_session_if_epoch( + &initial, + "old-access".to_string(), + Some("old-refresh".to_string()), + "old-user".to_string(), + Arc::clone(&old_session), + ) + .unwrap() + .expect("install old auth"); + + let new_epoch = manager + .replace_user_tokens( + "new-access".to_string(), + Some("new-refresh".to_string()), + Some("new-user".to_string()), + ) + .unwrap(); + assert!(manager + .replace_user_tokens_and_session_if_epoch( + &old_epoch, + "stale-access".to_string(), + Some("stale-refresh".to_string()), + "old-user".to_string(), + old_session, + ) + .unwrap() + .is_none()); + assert!(!manager.invalidate_user_auth_if_epoch(&old_epoch).unwrap()); + + let snapshot = manager.get_credential_snapshot().unwrap(); + assert_eq!(snapshot.auth_epoch, new_epoch); + assert_eq!(snapshot.auth_epoch.principal.as_deref(), Some("new-user")); + assert_eq!( + snapshot.tokens.expect("new tokens").access_token, + "new-access" + ); + assert!(snapshot.user_session.is_none()); + } + + #[test] + fn rejected_refresh_invalidates_only_its_own_auth_lifecycle() { + let manager = SessionManager::new(); + let initial = manager.get_credential_snapshot().unwrap().auth_epoch; + let rejected_epoch = manager + .replace_user_tokens_and_session_if_epoch( + &initial, + "access".to_string(), + Some("refresh".to_string()), + "user".to_string(), + v2_session(0x22), + ) + .unwrap() + .expect("install rejected auth"); + + assert!(manager + .invalidate_user_auth_if_epoch(&rejected_epoch) + .unwrap()); + let signed_out = manager.get_credential_snapshot().unwrap(); + assert!(signed_out.tokens.is_none()); + assert!(signed_out.auth_epoch.principal.is_none()); + assert!(signed_out.user_session.is_none()); + + let replacement = manager + .replace_user_tokens( + "replacement-access".to_string(), + Some("replacement-refresh".to_string()), + Some("replacement-user".to_string()), + ) + .unwrap(); + assert!(!manager + .invalidate_user_auth_if_epoch(&rejected_epoch) + .unwrap()); + assert_eq!( + manager.get_credential_snapshot().unwrap().auth_epoch, + replacement + ); + } } diff --git a/sdk/rust/src/transport_v2/auth_bundle.rs b/sdk/rust/src/transport_v2/auth_bundle.rs new file mode 100644 index 000000000..b827b9141 --- /dev/null +++ b/sdk/rust/src/transport_v2/auth_bundle.rs @@ -0,0 +1,321 @@ +use base64::{ + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, + Engine as _, +}; +use serde::{Deserialize, Serialize}; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; + +use super::{Result, TransportV2Error}; + +const BUNDLE_VERSION: u8 = 2; +const MAX_BASE_URL_BYTES: usize = 4 * 1024; +const MAX_DESCRIPTOR_BYTES: usize = 16 * 1024; +const MAX_BUNDLE_JSON_BYTES: usize = 64 * 1024; +const TOKEN_ISSUER: &str = "urn:opensecret:transport-v2"; +const USER_ACCESS_AUDIENCE: &str = "urn:opensecret:internal:transport-v2:user:access-descriptor"; +const USER_RESUMPTION_AUDIENCE: &str = "urn:opensecret:internal:transport-v2:user:resumption"; + +#[derive(Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] +#[serde(deny_unknown_fields)] +struct AuthBundle { + version: u8, + api_origin: String, + access_token: String, + refresh_token: String, + cache_namespace_root_base64: String, +} + +#[derive(Deserialize)] +struct TransportV2TokenClaims { + iss: String, + aud: String, + tv: u8, + tk: String, + pk: String, + sub: String, + exp: u64, +} + +pub(crate) struct DecodedAuthBundle { + pub(crate) access_token: String, + pub(crate) refresh_token: String, + pub(crate) cache_namespace_root: [u8; 32], +} + +impl Drop for DecodedAuthBundle { + fn drop(&mut self) { + self.access_token.zeroize(); + self.refresh_token.zeroize(); + self.cache_namespace_root.zeroize(); + } +} + +pub(crate) fn encode_auth_bundle( + api_origin: &str, + access_token: &str, + refresh_token: &str, + cache_namespace_root: &[u8; 32], +) -> Result { + validate_fields(api_origin, access_token, refresh_token)?; + validate_v2_user_token_pair(access_token, refresh_token)?; + let bundle = AuthBundle { + version: BUNDLE_VERSION, + api_origin: api_origin.to_owned(), + access_token: access_token.to_owned(), + refresh_token: refresh_token.to_owned(), + cache_namespace_root_base64: STANDARD.encode(cache_namespace_root), + }; + let json = + Zeroizing::new(serde_json::to_vec(&bundle).map_err(|_| TransportV2Error::InvalidJson)?); + if json.len() > MAX_BUNDLE_JSON_BYTES { + return Err(TransportV2Error::LimitExceeded { + field: "auth bundle", + limit: MAX_BUNDLE_JSON_BYTES, + }); + } + Ok(URL_SAFE_NO_PAD.encode(json.as_slice())) +} + +pub(crate) fn decode_auth_bundle( + encoded: &str, + expected_api_origin: &str, +) -> Result { + if encoded.is_empty() || encoded.contains('=') { + return Err(TransportV2Error::InvalidEncoding); + } + let json = Zeroizing::new( + URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| TransportV2Error::InvalidEncoding)?, + ); + if json.len() > MAX_BUNDLE_JSON_BYTES || URL_SAFE_NO_PAD.encode(json.as_slice()) != encoded { + return Err(TransportV2Error::InvalidEncoding); + } + let mut bundle: AuthBundle = + serde_json::from_slice(&json).map_err(|_| TransportV2Error::InvalidJson)?; + if bundle.version != BUNDLE_VERSION || bundle.api_origin != expected_api_origin { + return Err(TransportV2Error::BindingMismatch); + } + validate_fields( + &bundle.api_origin, + &bundle.access_token, + &bundle.refresh_token, + )?; + validate_v2_user_token_pair(&bundle.access_token, &bundle.refresh_token)?; + let root = Zeroizing::new( + STANDARD + .decode(&bundle.cache_namespace_root_base64) + .map_err(|_| TransportV2Error::InvalidEncoding)?, + ); + if root.len() != 32 || STANDARD.encode(root.as_slice()) != bundle.cache_namespace_root_base64 { + return Err(TransportV2Error::InvalidEncoding); + } + let mut cache_namespace_root = [0_u8; 32]; + cache_namespace_root.copy_from_slice(root.as_slice()); + if encode_auth_bundle( + &bundle.api_origin, + &bundle.access_token, + &bundle.refresh_token, + &cache_namespace_root, + )? != encoded + { + return Err(TransportV2Error::InvalidEncoding); + } + Ok(DecodedAuthBundle { + access_token: std::mem::take(&mut bundle.access_token), + refresh_token: std::mem::take(&mut bundle.refresh_token), + cache_namespace_root, + }) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ValidatedUserTokenPair { + pub(crate) principal: String, + pub(crate) access_expires_at_unix_seconds: u64, +} + +pub(crate) fn validate_v2_user_token_pair( + access_token: &str, + refresh_token: &str, +) -> Result { + let (access_subject, access_expires_at_unix_seconds) = + token_subject(access_token, USER_ACCESS_AUDIENCE, "access_descriptor")?; + let (resumption_subject, _) = + token_subject(refresh_token, USER_RESUMPTION_AUDIENCE, "resumption")?; + if access_subject != resumption_subject { + return Err(TransportV2Error::BindingMismatch); + } + Ok(ValidatedUserTokenPair { + principal: access_subject, + access_expires_at_unix_seconds, + }) +} + +fn token_subject( + token: &str, + expected_audience: &str, + expected_kind: &str, +) -> Result<(String, u64)> { + if token.is_empty() || token.len() > MAX_DESCRIPTOR_BYTES { + return Err(TransportV2Error::InvalidRequest); + } + let mut parts = token.split('.'); + let Some(header) = parts.next() else { + return Err(TransportV2Error::InvalidEncoding); + }; + let Some(payload) = parts.next() else { + return Err(TransportV2Error::InvalidEncoding); + }; + let Some(signature) = parts.next() else { + return Err(TransportV2Error::InvalidEncoding); + }; + if parts.next().is_some() || header.is_empty() || payload.is_empty() || signature.is_empty() { + return Err(TransportV2Error::InvalidEncoding); + } + let decoded = Zeroizing::new( + URL_SAFE_NO_PAD + .decode(payload) + .map_err(|_| TransportV2Error::InvalidEncoding)?, + ); + if decoded.len() > MAX_DESCRIPTOR_BYTES || URL_SAFE_NO_PAD.encode(decoded.as_slice()) != payload + { + return Err(TransportV2Error::InvalidEncoding); + } + let claims: TransportV2TokenClaims = + serde_json::from_slice(&decoded).map_err(|_| TransportV2Error::InvalidJson)?; + if claims.iss != TOKEN_ISSUER + || claims.aud != expected_audience + || claims.tv != BUNDLE_VERSION + || claims.tk != expected_kind + || claims.pk != "user" + || claims.sub.is_empty() + || claims.exp == 0 + { + return Err(TransportV2Error::InvalidRequest); + } + Ok((claims.sub, claims.exp)) +} + +fn validate_fields(api_origin: &str, access_token: &str, refresh_token: &str) -> Result<()> { + if api_origin.is_empty() || api_origin.len() > MAX_BASE_URL_BYTES { + return Err(TransportV2Error::InvalidRequest); + } + for token in [access_token, refresh_token] { + if token.is_empty() || token.len() > MAX_DESCRIPTOR_BYTES { + return Err(TransportV2Error::InvalidRequest); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn descriptor_with_expiry(audience: &str, kind: &str, subject: &str, expiry: u64) -> String { + let claims = serde_json::json!({ + "iss": TOKEN_ISSUER, + "aud": audience, + "tv": 2, + "tk": kind, + "pk": "user", + "sub": subject, + "exp": expiry, + }); + format!( + "e30.{}.signature", + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()) + ) + } + + fn descriptor(audience: &str, kind: &str, subject: &str) -> String { + descriptor_with_expiry(audience, kind, subject, 2_000_000_000_u64) + } + + fn descriptors(subject: &str) -> (String, String) { + ( + descriptor(USER_ACCESS_AUDIENCE, "access_descriptor", subject), + descriptor(USER_RESUMPTION_AUDIENCE, "resumption", subject), + ) + } + + #[test] + fn bundle_round_trip_is_canonical_and_origin_bound() { + let root = [7_u8; 32]; + let (access, refresh) = descriptors("user-1"); + let encoded = + encode_auth_bundle("https://example.com/api", &access, &refresh, &root).unwrap(); + assert!(!encoded.contains('=')); + let decoded = decode_auth_bundle(&encoded, "https://example.com/api").unwrap(); + assert_eq!(decoded.access_token, access); + assert_eq!(decoded.refresh_token, refresh); + assert_eq!(decoded.cache_namespace_root, root); + assert!(matches!( + decode_auth_bundle(&encoded, "https://other.example"), + Err(TransportV2Error::BindingMismatch) + )); + } + + #[test] + fn bundle_rejects_unknown_duplicate_and_noncanonical_fields() { + let root = STANDARD.encode([9_u8; 32]); + let (access, refresh) = descriptors("user-1"); + for json in [ + format!( + r#"{{"version":2,"api_origin":"https://example.com","access_token":"{access}","refresh_token":"{refresh}","cache_namespace_root_base64":"{root}","extra":true}}"# + ), + format!( + r#"{{"version":2,"version":2,"api_origin":"https://example.com","access_token":"{access}","refresh_token":"{refresh}","cache_namespace_root_base64":"{root}"}}"# + ), + format!( + r#"{{"api_origin":"https://example.com","version":2,"access_token":"{access}","refresh_token":"{refresh}","cache_namespace_root_base64":"{root}"}}"# + ), + ] { + let encoded = URL_SAFE_NO_PAD.encode(json.as_bytes()); + assert!(decode_auth_bundle(&encoded, "https://example.com").is_err()); + } + + let canonical = + encode_auth_bundle("https://example.com", &access, &refresh, &[9_u8; 32]).unwrap(); + assert!(decode_auth_bundle(&format!("{canonical}="), "https://example.com").is_err()); + } + + #[test] + fn descriptors_are_kind_audience_and_principal_bound() { + let (access, refresh) = descriptors("user-1"); + assert_eq!( + validate_v2_user_token_pair(&access, &refresh).unwrap(), + ValidatedUserTokenPair { + principal: "user-1".to_string(), + access_expires_at_unix_seconds: 2_000_000_000, + } + ); + let (_, other_refresh) = descriptors("user-2"); + assert!(validate_v2_user_token_pair(&access, &other_refresh).is_err()); + assert!(validate_v2_user_token_pair(&refresh, &access).is_err()); + assert!(validate_v2_user_token_pair("legacy", "legacy").is_err()); + } + + #[test] + fn validation_returns_the_access_descriptor_deadline() { + let access = descriptor_with_expiry( + USER_ACCESS_AUDIENCE, + "access_descriptor", + "user-1", + 1_900_000_123, + ); + let refresh = descriptor_with_expiry( + USER_RESUMPTION_AUDIENCE, + "resumption", + "user-1", + 2_100_000_456, + ); + assert_eq!( + validate_v2_user_token_pair(&access, &refresh).unwrap(), + ValidatedUserTokenPair { + principal: "user-1".to_string(), + access_expires_at_unix_seconds: 1_900_000_123, + } + ); + } +} diff --git a/sdk/rust/src/transport_v2/crypto.rs b/sdk/rust/src/transport_v2/crypto.rs index e0f85c7db..c309254d8 100644 --- a/sdk/rust/src/transport_v2/crypto.rs +++ b/sdk/rust/src/transport_v2/crypto.rs @@ -40,6 +40,7 @@ const STREAM_RESPONSE_RECORD_AAD: &[u8] = b"opensecret/transport-v2/stream-respo pub(super) struct SessionMaster([u8; KEY_LEN]); impl SessionMaster { + #[cfg(test)] pub(super) const fn from_bytes(bytes: [u8; KEY_LEN]) -> Self { Self(bytes) } @@ -288,6 +289,7 @@ pub(super) fn decrypt_key_exchange_record( }) } +#[cfg(test)] pub(super) fn encode_canonical_base64(bytes: &[u8]) -> String { STANDARD.encode(bytes) } diff --git a/sdk/rust/src/transport_v2/envelope.rs b/sdk/rust/src/transport_v2/envelope.rs index 373e1efe3..be0f3ef20 100644 --- a/sdk/rust/src/transport_v2/envelope.rs +++ b/sdk/rust/src/transport_v2/envelope.rs @@ -2,6 +2,7 @@ use std::fmt; use base64::{engine::general_purpose::STANDARD, Engine as _}; use p256::elliptic_curve::rand_core::{OsRng, RngCore}; +#[cfg(test)] use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use uuid::Uuid; @@ -12,7 +13,10 @@ use super::{Result, TransportV2Error}; const KIB: usize = 1024; const MIB: usize = 1024 * 1024; -pub(super) const MAX_OUTER_REQUEST_BYTES: usize = 50 * MIB; +// The raw AEAD carrier is one ChaCha20-Poly1305 record: the encrypted JSON +// envelope plus a 12-byte nonce and 16-byte authentication tag. +pub(super) const MAX_OUTER_REQUEST_BYTES: usize = 67 * MIB + 28; +pub(super) const MAX_OUTER_RESPONSE_BYTES: usize = 50 * MIB + 28; pub(super) const MAX_KEY_EXCHANGE_BYTES: usize = 4 * KIB; pub(super) const MAX_STREAM_CHUNK_BYTES: usize = 64 * KIB; pub(super) const MAX_STREAM_ERROR_BYTES: usize = 16 * KIB; @@ -44,6 +48,18 @@ pub(super) struct EnvelopeLimits { impl EnvelopeLimits { pub(super) const DEFAULT: Self = Self { + envelope_bytes: 67 * MIB, + logical_body_bytes: 50 * MIB, + path_bytes: 4096, + query_bytes: 8192, + header_count: 64, + header_name_bytes: 128, + header_value_bytes: 16 * KIB, + aggregate_header_bytes: 64 * KIB, + credential_bytes: 16 * KIB, + }; + + pub(super) const RESPONSE: Self = Self { envelope_bytes: 50 * MIB, logical_body_bytes: 28 * MIB, path_bytes: 4096, @@ -105,6 +121,7 @@ impl RequestId { Ok(Self(bytes)) } + #[cfg(test)] pub(super) const fn from_bytes(bytes: [u8; 16]) -> Self { Self(bytes) } @@ -188,7 +205,7 @@ fn hex_nibble(byte: u8) -> Option { /// Exact bytes represented on the wire as padded standard base64. #[derive(Clone, Eq, PartialEq, Zeroize, ZeroizeOnDrop)] -pub(super) struct EncodedBytes(Vec); +pub(crate) struct EncodedBytes(Vec); impl EncodedBytes { pub(super) fn from_bytes(bytes: impl Into>) -> Self { @@ -207,6 +224,7 @@ impl EncodedBytes { self.0.len() } + #[cfg(test)] pub(super) fn is_empty(&self) -> bool { self.0.is_empty() } @@ -268,24 +286,12 @@ impl<'de> Deserialize<'de> for EncodedBytes { /// Stable client-generated provider-cache namespace root. #[derive(Eq, PartialEq, Zeroize, ZeroizeOnDrop)] -pub(super) struct CacheNamespaceRoot([u8; 32]); +pub(crate) struct CacheNamespaceRoot([u8; 32]); impl CacheNamespaceRoot { - pub(super) fn random() -> Result { - let mut root = Self([0_u8; 32]); - OsRng - .try_fill_bytes(&mut root.0) - .map_err(|_| TransportV2Error::RandomnessUnavailable)?; - Ok(root) - } - - pub(super) const fn from_bytes(bytes: [u8; 32]) -> Self { + pub(crate) const fn from_bytes(bytes: [u8; 32]) -> Self { Self(bytes) } - - pub(super) const fn as_bytes(&self) -> &[u8; 32] { - &self.0 - } } impl fmt::Debug for CacheNamespaceRoot { @@ -324,19 +330,19 @@ impl<'de> Deserialize<'de> for CacheNamespaceRoot { /// Authentication material permitted only during an anonymous transition. #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] -pub(super) enum Credential { +pub(crate) enum Credential { ApiKey { value_base64: EncodedBytes }, Resumption { value_base64: EncodedBytes }, } impl Credential { - pub(super) fn api_key(bytes: impl Into>) -> Self { + pub(crate) fn api_key(bytes: impl Into>) -> Self { Self::ApiKey { value_base64: EncodedBytes::from_bytes(bytes), } } - pub(super) fn resumption(bytes: impl Into>) -> Self { + pub(crate) fn resumption(bytes: impl Into>) -> Self { Self::Resumption { value_base64: EncodedBytes::from_bytes(bytes), } @@ -351,14 +357,14 @@ impl Credential { #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub(super) enum ResponseMode { +pub(crate) enum ResponseMode { Unary, Stream, Auto, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub(super) enum LogicalMethod { +pub(crate) enum LogicalMethod { #[serde(rename = "GET")] Get, #[serde(rename = "POST")] @@ -371,27 +377,15 @@ pub(super) enum LogicalMethod { Delete, } -impl LogicalMethod { - pub(super) const fn as_str(self) -> &'static str { - match self { - Self::Get => "GET", - Self::Post => "POST", - Self::Put => "PUT", - Self::Patch => "PATCH", - Self::Delete => "DELETE", - } - } -} - #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub(super) struct HeaderField { +pub(crate) struct HeaderField { pub(super) name: String, pub(super) value_base64: EncodedBytes, } impl HeaderField { - pub(super) fn new(name: impl Into, value: impl Into>) -> Self { + pub(crate) fn new(name: impl Into, value: impl Into>) -> Self { Self { name: name.into(), value_base64: EncodedBytes::from_bytes(value), @@ -405,7 +399,7 @@ impl HeaderField { #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub(super) struct LogicalRequest { +pub(crate) struct LogicalRequest { pub(super) method: LogicalMethod, pub(super) path: String, #[serde(deserialize_with = "deserialize_required_nullable")] @@ -416,7 +410,7 @@ pub(super) struct LogicalRequest { } impl LogicalRequest { - pub(super) fn new( + pub(crate) fn new( method: LogicalMethod, path: impl Into, query: Option, @@ -459,6 +453,7 @@ pub(super) struct RequestEnvelope { } impl RequestEnvelope { + #[cfg(test)] pub(super) fn from_json_slice(input: &[u8], limits: &EnvelopeLimits) -> Result { check_limit(input.len(), limits.envelope_bytes, "envelope")?; let envelope: Self = @@ -610,25 +605,6 @@ impl StreamRecord { } } -#[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) struct EncryptedOuterRecord { - pub(super) encrypted: EncodedBytes, -} - -impl EncryptedOuterRecord { - pub(super) fn from_json_slice(input: &[u8], limit: usize) -> Result { - check_limit(input.len(), limit, "outer record")?; - serde_json::from_slice(input).map_err(|_| TransportV2Error::InvalidJson) - } - - pub(super) fn to_json_vec(&self, limit: usize) -> Result> { - let encoded = serde_json::to_vec(self).map_err(|_| TransportV2Error::InvalidJson)?; - check_limit(encoded.len(), limit, "outer record")?; - Ok(encoded) - } -} - fn deserialize_required_nullable<'de, D, T>( deserializer: D, ) -> std::result::Result, D::Error> @@ -721,6 +697,7 @@ fn validate_path(method: LogicalMethod, path: &str, limits: &EnvelopeLimits) -> /// Encode one opaque UTF-8 final path segment exactly as the released Rust SDK /// does: ASCII alphanumeric bytes remain literal and every other byte becomes /// one uppercase `%HH` triplet. +#[cfg(test)] pub(super) fn encode_canonical_opaque_path_segment(value: &str) -> String { utf8_percent_encode(value, NON_ALPHANUMERIC).to_string() } @@ -1119,8 +1096,3 @@ fn is_lowercase_http_token(byte: u8) -> bool { | b'~' ) } - -#[cfg(test)] -pub(super) fn encode_canonical_base64(bytes: &[u8]) -> String { - STANDARD.encode(bytes) -} diff --git a/sdk/rust/src/transport_v2/mod.rs b/sdk/rust/src/transport_v2/mod.rs index 9c0b21c1c..4ac63dee9 100644 --- a/sdk/rust/src/transport_v2/mod.rs +++ b/sdk/rust/src/transport_v2/mod.rs @@ -1,17 +1,27 @@ -//! Dormant client engine for OpenSecret transport v2. +//! Private client engine for OpenSecret transport v2. //! -//! Nothing in this module is selected by [`crate::OpenSecretClient`] yet. The -//! cutover layer will adapt existing public methods onto these primitives in a -//! later change. Keeping this module private prevents an incomplete transport -//! from becoming a compatibility surface. - -#![allow(dead_code)] +//! [`crate::OpenSecretClient`] is the stable public adapter. Keeping the wire +//! primitives private prevents protocol details from becoming an accidental +//! compatibility surface. +mod auth_bundle; mod crypto; mod envelope; +mod network; +mod runtime; mod session; mod stream; +pub(crate) use auth_bundle::{ + decode_auth_bundle, encode_auth_bundle, validate_v2_user_token_pair, ValidatedUserTokenPair, +}; +pub(crate) use envelope::{ + CacheNamespaceRoot, Credential, HeaderField, LogicalMethod, LogicalRequest, ResponseMode, +}; +pub(crate) use network::{TransportV2Client, V2HttpResponse}; +pub(crate) use runtime::ApiKeyScope; +pub(crate) use session::V2Session; + use thiserror::Error; /// Stable failures from the dormant transport-v2 engine. @@ -71,5 +81,37 @@ pub(super) enum TransportV2Error { pub(super) type Result = std::result::Result; +impl From for crate::error::Error { + fn from(error: TransportV2Error) -> Self { + match error { + TransportV2Error::SessionExpired + | TransportV2Error::RequestRecordBudgetExhausted + | TransportV2Error::ResponseRecordBudgetExhausted + | TransportV2Error::SessionStateUnavailable => Self::Session(error.to_string()), + TransportV2Error::InvalidRequest | TransportV2Error::LimitExceeded { .. } => { + Self::Configuration(error.to_string()) + } + TransportV2Error::InvalidKeyExchange + | TransportV2Error::NonContributoryKeyExchange + | TransportV2Error::KeyDerivationFailed => Self::KeyExchange(error.to_string()), + TransportV2Error::EncryptionFailed | TransportV2Error::RandomnessUnavailable => { + Self::Encryption(error.to_string()) + } + TransportV2Error::AuthenticationFailed + | TransportV2Error::RecordTooShort + | TransportV2Error::InvalidEncoding + | TransportV2Error::InvalidJson + | TransportV2Error::InvalidResponse + | TransportV2Error::ResponseModeMismatch + | TransportV2Error::RequestIdCollision + | TransportV2Error::BindingMismatch + | TransportV2Error::InvalidStreamFraming + | TransportV2Error::InvalidStreamRecord + | TransportV2Error::TruncatedStream + | TransportV2Error::StreamAlreadyTerminal => Self::InvalidResponse(error.to_string()), + } + } +} + #[cfg(test)] mod tests; diff --git a/sdk/rust/src/transport_v2/network.rs b/sdk/rust/src/transport_v2/network.rs new file mode 100644 index 000000000..d6ae3093d --- /dev/null +++ b/sdk/rust/src/transport_v2/network.rs @@ -0,0 +1,814 @@ +use std::{ + collections::{HashMap, VecDeque}, + net::IpAddr, + pin::Pin, + sync::Arc, +}; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use bytes::{Bytes, BytesMut}; +use futures::{Stream, StreamExt}; +use http::{HeaderMap, HeaderName, HeaderValue, Response, StatusCode}; +use p256::elliptic_curve::rand_core::{OsRng, RngCore}; +use reqwest::{header, redirect::Policy, Client}; +use zeroize::Zeroizing; + +use crate::{ + attestation::{AttestationDocument, AttestationVerifier}, + cbor::{self, Value as CborValue}, + error::{Error, Result}, + pcr::Pcr0TrustPolicy, + session::SessionManager, + types::AttestationResponse, +}; + +use super::{ + envelope::{ + CacheNamespaceRoot, Credential, HeaderField, LogicalRequest, ResponseMode, + MAX_KEY_EXCHANGE_BYTES, MAX_OUTER_RESPONSE_BYTES, + }, + runtime::{ApiKeyScope, TransportV2Runtime}, + session::{PreparedKeyExchange, PreparedRequest, ResponseContext, V2Session}, + stream::StreamEvent, + TransportV2Error, +}; + +const MAX_ATTESTATION_RESPONSE_BYTES: usize = 2 * 1024 * 1024; + +pub(crate) type V2ResponseBody = Pin> + Send + 'static>>; +pub(crate) type V2HttpResponse = Response; + +pub(crate) struct TransportV2Client { + client: Client, + base_url: String, + use_mock_attestation: bool, + pcr0_trust_policy: Pcr0TrustPolicy, + runtime: Arc, +} + +impl TransportV2Client { + pub(crate) fn new( + base_url: String, + pcr0_trust_policy: Pcr0TrustPolicy, + cache_namespace_root: [u8; 32], + session_manager: SessionManager, + ) -> Result { + let (base_url, use_mock_attestation) = canonical_base_url(base_url)?; + let client = Client::builder().redirect(Policy::none()).build()?; + Ok(Self { + client, + base_url, + use_mock_attestation, + pcr0_trust_policy, + runtime: Arc::new(TransportV2Runtime::new( + cache_namespace_root, + session_manager, + )), + }) + } + + pub(crate) fn with_cache_namespace_root(mut self, root: [u8; 32]) -> Self { + self.runtime = Arc::new(TransportV2Runtime::new( + root, + self.runtime.session_manager(), + )); + self + } + + pub(crate) const fn base_url(&self) -> &str { + self.base_url.as_str() + } + + pub(crate) const fn http_client(&self) -> &Client { + &self.client + } + + pub(crate) fn cache_namespace_root(&self) -> Result<[u8; 32]> { + self.runtime.cache_namespace_root().map_err(Into::into) + } + + pub(crate) fn replace_cache_namespace_root(&self, root: [u8; 32]) -> Result<()> { + self.runtime + .replace_cache_namespace_root(root) + .map_err(Into::into) + } + + pub(crate) fn active_session_id(&self) -> Result> { + self.runtime.active_session_id().map_err(Into::into) + } + + pub(crate) fn anonymous_session(&self) -> Result>> { + self.runtime.anonymous().map_err(Into::into) + } + + #[cfg(test)] + pub(crate) fn set_anonymous_session_for_test(&self, session: Arc) -> Result<()> { + self.runtime.set_anonymous(session).map_err(Into::into) + } + + pub(crate) fn clear_anonymous_session_if(&self, session: &Arc) -> Result<()> { + self.runtime.clear_anonymous_if(session).map_err(Into::into) + } + + pub(crate) fn clear_user_session_if(&self, session: &Arc) -> Result<()> { + self.runtime.clear_user_if(session).map_err(Into::into) + } + + pub(crate) fn api_key_session(&self, scope: &ApiKeyScope) -> Result>> { + self.runtime.api_key(scope).map_err(Into::into) + } + + pub(crate) fn set_api_key_session( + &self, + scope: &ApiKeyScope, + session: Arc, + ) -> Result<()> { + self.runtime.set_api_key(scope, session).map_err(Into::into) + } + + pub(crate) fn clear_api_key_sessions(&self) -> Result<()> { + self.runtime.clear_api_key().map_err(Into::into) + } + + pub(crate) fn clear_api_key_session_if( + &self, + scope: &ApiKeyScope, + session: &Arc, + ) -> Result<()> { + self.runtime + .clear_api_key_if(scope, session) + .map_err(Into::into) + } + + pub(crate) fn user_gate(&self) -> &tokio::sync::Mutex<()> { + self.runtime.user_gate() + } + + pub(crate) fn api_key_gate(&self) -> &tokio::sync::Mutex<()> { + self.runtime.api_key_gate() + } + + pub(crate) async fn perform_attestation_handshake(&self) -> Result> { + let _guard = self.runtime.anonymous_gate().lock().await; + if let Some(session) = self.runtime.anonymous().map_err(Error::from)? { + if !session.is_expired()? { + return Ok(session); + } + self.runtime + .clear_anonymous_if(&session) + .map_err(Error::from)?; + } + let session = self.establish_fresh_session().await?; + self.runtime + .set_anonymous(Arc::clone(&session)) + .map_err(Error::from)?; + Ok(session) + } + + pub(crate) async fn fresh_session(&self) -> Result> { + self.establish_fresh_session().await + } + + async fn send_prepared( + &self, + prepared: PreparedRequest, + session: Arc, + ) -> Result { + let session_id = prepared.session_id(); + let response_mode = prepared.response_mode(); + let (body, response_context) = prepared.into_parts(); + let response = self + .client + .post(format!("{}/v2/request", self.base_url)) + .header(header::CONTENT_TYPE, "application/octet-stream") + .header("x-session-id", session_id.hyphenated().to_string()) + .body(body) + .send() + .await?; + + let outer_status = response.status(); + let content_type = exact_content_type(response.headers()); + + match response_mode { + super::envelope::ResponseMode::Unary => { + if outer_status != reqwest::StatusCode::OK + || content_type != Some("application/octet-stream") + { + return Err(untrusted_outer_error(outer_status)); + } + let body = read_bounded_response(response, MAX_OUTER_RESPONSE_BYTES).await?; + let logical = response_context.decrypt_unary_outer(&body)?; + retire_authenticated_session_if_exhausted( + &self.runtime, + &session, + logical.status, + logical.body.as_deref(), + )?; + logical_response(logical.status, logical.headers, logical.body) + } + super::envelope::ResponseMode::Stream => match content_type { + Some("application/octet-stream") if outer_status == reqwest::StatusCode::OK => { + let body = read_bounded_response(response, MAX_OUTER_RESPONSE_BYTES).await?; + let logical = response_context.decrypt_stream_pre_start_error_outer(&body)?; + retire_authenticated_session_if_exhausted( + &self.runtime, + &session, + logical.status, + logical.body.as_deref(), + )?; + logical_response(logical.status, logical.headers, logical.body) + } + Some("text/event-stream") if outer_status == reqwest::StatusCode::OK => { + stream_response( + response, + response_context, + Arc::clone(&self.runtime), + session, + ) + .await + } + _ => Err(untrusted_outer_error(outer_status)), + }, + super::envelope::ResponseMode::Auto => Err(Error::InvalidResponse( + "Transport v2 returned a reserved response mode".to_string(), + )), + } + } + + pub(crate) async fn send_request( + &self, + session: &Arc, + response_mode: ResponseMode, + credential: Option, + cache_namespace_root: Option, + request: LogicalRequest, + ) -> Result { + let prepared = + match session.prepare_request(response_mode, credential, cache_namespace_root, request) + { + Ok(prepared) => prepared, + Err(error) => { + if matches!( + error, + TransportV2Error::SessionExpired + | TransportV2Error::RequestRecordBudgetExhausted + | TransportV2Error::ResponseRecordBudgetExhausted + ) { + self.runtime + .clear_session_if(session) + .map_err(Error::from)?; + } + return Err(error.into()); + } + }; + match self.send_prepared(prepared, Arc::clone(session)).await { + Ok(response) => Ok(response), + Err(error) => { + self.runtime + .clear_session_if(session) + .map_err(Error::from)?; + Err(error) + } + } + } + + async fn establish_fresh_session(&self) -> Result> { + let nonce = fresh_attestation_nonce()?; + let response = self + .client + .get(format!("{}/v2/attestation/{nonce}", self.base_url)) + .send() + .await?; + if !response.status().is_success() { + return Err(untrusted_outer_error(response.status())); + } + let body = read_bounded_response(response, MAX_ATTESTATION_RESPONSE_BYTES).await?; + let attestation: AttestationResponse = serde_json::from_slice(&body)?; + + let document = if self.use_mock_attestation { + parse_mock_attestation(&attestation.attestation_document)? + } else { + let verifier = AttestationVerifier::new(); + let document = + verifier.verify_attestation_document(&attestation.attestation_document, &nonce)?; + let pcr0 = document.pcrs.get(&0).ok_or_else(|| { + Error::AttestationVerificationFailed( + "Missing PCR0 in attestation document".to_string(), + ) + })?; + self.pcr0_trust_policy.verify_pcr0(pcr0).await?; + document + }; + if document.nonce.as_deref() != Some(nonce.as_bytes()) { + return Err(Error::AttestationVerificationFailed( + "Attestation nonce did not match the fresh client challenge".to_string(), + )); + } + let enclave_public_key: [u8; 32] = document + .public_key + .ok_or_else(|| { + Error::AttestationVerificationFailed( + "Attestation document did not contain an enclave public key".to_string(), + ) + })? + .try_into() + .map_err(|_| { + Error::AttestationVerificationFailed( + "Attested enclave public key had the wrong length".to_string(), + ) + })?; + + // Key exchange is one-shot. Once this POST is attempted, the SDK never + // transparently reuses the prepared client secret or nonce. + let prepared = PreparedKeyExchange::new(nonce, enclave_public_key)?; + let (body, completion) = prepared.into_parts(); + let response = self + .client + .post(format!("{}/v2/key_exchange", self.base_url)) + .header(header::CONTENT_TYPE, "application/json") + .body(body) + .send() + .await?; + if !response.status().is_success() { + return Err(untrusted_outer_error(response.status())); + } + let body = read_bounded_response(response, MAX_KEY_EXCHANGE_BYTES).await?; + Ok(Arc::new(completion.complete(&body)?)) + } +} + +fn exact_content_type(headers: &HeaderMap) -> Option<&str> { + let mut values = headers.get_all(header::CONTENT_TYPE).iter(); + let value = values.next()?.to_str().ok()?; + if values.next().is_some() { + return None; + } + Some(value) +} + +async fn stream_response( + response: reqwest::Response, + response_context: ResponseContext, + runtime: Arc, + session: Arc, +) -> Result { + let mut source = response.bytes_stream(); + let mut decoder = response_context.into_stream_decoder()?; + let mut pending = VecDeque::new(); + let (status, headers) = 'start: loop { + let Some(chunk) = source.next().await else { + decoder.finish()?; + return Err(Error::InvalidResponse( + "Transport v2 stream ended before its authenticated start".to_string(), + )); + }; + let mut events = decoder.push(&chunk?)?.into_iter(); + let Some(event) = events.next() else { + continue; + }; + match event { + StreamEvent::Start { status, headers } => { + pending.extend(events); + break 'start (status, headers); + } + _ => { + return Err(Error::InvalidResponse( + "Transport v2 stream emitted data before its authenticated start".to_string(), + )) + } + } + }; + + let body = async_stream::try_stream! { + let mut terminal = false; + loop { + while let Some(event) = pending.pop_front() { + match event { + StreamEvent::Start { .. } => Err(Error::InvalidResponse( + "Transport v2 stream emitted more than one start record".to_string(), + ))?, + StreamEvent::Chunk(bytes) => yield Bytes::from(bytes), + StreamEvent::End => terminal = true, + StreamEvent::Error { status, body } => Err(Error::Api { + status, + message: String::from_utf8_lossy(&body).into_owned(), + })?, + } + } + + let Some(chunk) = source.next().await else { + decoder.finish()?; + if !terminal { + Err(Error::InvalidResponse( + "Transport v2 stream lacked an authenticated terminal record".to_string(), + ))?; + } + break; + }; + pending.extend(decoder.push(&chunk?)?); + } + } + .map(move |item| retire_session_on_failed_stream_item(&runtime, &session, item)); + + build_response(status, headers, Box::pin(body)) +} + +fn retire_session_on_failed_stream_item( + runtime: &TransportV2Runtime, + session: &Arc, + item: Result, +) -> Result { + let retires_session = item.as_ref().is_err_and(|error| match error { + Error::Api { status, message } => { + authenticated_session_exhausted(*status, message.as_bytes()) + } + _ => true, + }); + if retires_session { + runtime.clear_session_if(session).map_err(Error::from)?; + } + item +} + +fn retire_authenticated_session_if_exhausted( + runtime: &TransportV2Runtime, + session: &Arc, + status: u16, + body: Option<&[u8]>, +) -> Result<()> { + if body.is_some_and(|body| authenticated_session_exhausted(status, body)) { + runtime.clear_session_if(session).map_err(Error::from)?; + } + Ok(()) +} + +fn authenticated_session_exhausted(status: u16, body: &[u8]) -> bool { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct ProtocolError { + error: ProtocolErrorDetails, + } + + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct ProtocolErrorDetails { + code: String, + message: String, + } + + if status != StatusCode::SERVICE_UNAVAILABLE.as_u16() { + return false; + } + serde_json::from_slice::(body).is_ok_and(|error| { + error.error.code == "session_exhausted" && !error.error.message.is_empty() + }) +} + +fn logical_response( + status: u16, + headers: Vec, + body: Option>, +) -> Result { + let body = Bytes::from(body.unwrap_or_default()); + build_response( + status, + headers, + Box::pin(futures::stream::once(async move { Ok(body) })), + ) +} + +fn build_response( + status: u16, + headers: Vec, + body: V2ResponseBody, +) -> Result { + let status = StatusCode::from_u16(status).map_err(|_| { + Error::InvalidResponse("Transport v2 returned an invalid logical status".to_string()) + })?; + let mut response = Response::builder() + .status(status) + .body(body) + .map_err(|error| { + Error::InvalidResponse(format!("Failed to construct logical response: {error}")) + })?; + *response.headers_mut() = logical_headers(headers)?; + Ok(response) +} + +fn logical_headers(headers: Vec) -> Result { + let mut output = HeaderMap::new(); + for header in headers { + let name = HeaderName::from_bytes(header.name.as_bytes()).map_err(|_| { + Error::InvalidResponse("Transport v2 returned an invalid header name".to_string()) + })?; + let value = HeaderValue::from_bytes(header.value()).map_err(|_| { + Error::InvalidResponse("Transport v2 returned an invalid header value".to_string()) + })?; + output.append(name, value); + } + Ok(output) +} + +async fn read_bounded_response(response: reqwest::Response, limit: usize) -> Result { + let mut source = response.bytes_stream(); + let mut body = BytesMut::new(); + while let Some(chunk) = source.next().await { + let chunk = chunk?; + let next = body.len().checked_add(chunk.len()).ok_or_else(|| { + Error::InvalidResponse("Transport response size overflowed".to_string()) + })?; + if next > limit { + return Err(Error::InvalidResponse(format!( + "Transport response exceeded the {limit}-byte limit" + ))); + } + body.extend_from_slice(&chunk); + } + Ok(body.freeze()) +} + +fn fresh_attestation_nonce() -> Result { + let mut nonce = Zeroizing::new([0_u8; 32]); + OsRng + .try_fill_bytes(&mut *nonce) + .map_err(|_| Error::Crypto("Secure randomness was unavailable".to_string()))?; + Ok(hex::encode(*nonce)) +} + +fn untrusted_outer_error(status: reqwest::StatusCode) -> Error { + let status = status.as_u16(); + Error::Api { + status, + message: format!( + "Transport v2 request failed before an authenticated logical response (outer status {status})" + ), + } +} + +fn canonical_base_url(base_url: String) -> Result<(String, bool)> { + let mut parsed = reqwest::Url::parse(&base_url) + .map_err(|error| Error::Configuration(format!("Invalid base URL: {error}")))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(Error::Configuration( + "Base URL must use HTTP or HTTPS".to_string(), + )); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(Error::Configuration( + "Base URL must not contain credentials".to_string(), + )); + } + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err(Error::Configuration( + "Base URL must not contain a query or fragment".to_string(), + )); + } + let host = parsed + .host_str() + .ok_or_else(|| Error::Configuration("Base URL must include a host".to_string()))?; + let host = host.trim_end_matches('.'); + let is_mock_host = if host.eq_ignore_ascii_case("localhost") { + true + } else { + let address_host = host + .strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(host); + address_host.parse::().is_ok_and(|address| { + address.is_loopback() + || address.is_unspecified() + || (cfg!(target_os = "android") && address == IpAddr::from([10, 0, 2, 2])) + }) + }; + if parsed.scheme() != "https" && !is_mock_host { + return Err(Error::Configuration( + "Non-local base URLs must use HTTPS".to_string(), + )); + } + + let path = parsed.path().trim_end_matches('/').to_string(); + parsed.set_path(if path.is_empty() { "/" } else { &path }); + Ok(( + parsed.as_str().trim_end_matches('/').to_string(), + is_mock_host, + )) +} + +fn parse_mock_attestation(document_b64: &str) -> Result { + let document_bytes = STANDARD.decode(document_b64)?; + let cbor_value: CborValue = cbor::from_slice(&document_bytes)?; + let cose_sign1 = match &cbor_value { + CborValue::Array(values) if values.len() == 4 => values, + _ => { + return Err(Error::AttestationVerificationFailed( + "Invalid mock COSE_Sign1 structure".to_string(), + )) + } + }; + let payload = match &cose_sign1[2] { + CborValue::Bytes(bytes) => bytes, + _ => { + return Err(Error::AttestationVerificationFailed( + "Invalid mock attestation payload".to_string(), + )) + } + }; + let document: CborValue = cbor::from_slice(payload)?; + let map = match document { + CborValue::Map(map) => map, + _ => { + return Err(Error::AttestationVerificationFailed( + "Invalid mock attestation document".to_string(), + )) + } + }; + + let mut public_key = None; + let mut nonce = None; + for (key, value) in map { + let CborValue::Text(key) = key else { + continue; + }; + match (key.as_str(), value) { + ("public_key", CborValue::Bytes(bytes)) => public_key = Some(bytes), + ("nonce", CborValue::Bytes(bytes)) => nonce = Some(bytes), + _ => {} + } + } + Ok(AttestationDocument { + module_id: "mock-module".to_string(), + timestamp: 1, + digest: "SHA384".to_string(), + pcrs: HashMap::new(), + certificate: Vec::new(), + cabundle: Vec::new(), + public_key, + user_data: None, + nonce, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session(marker: u8) -> Arc { + Arc::new( + V2Session::from_master_for_test( + uuid::Uuid::from_bytes([marker; 16]), + [marker; 32], + u64::MAX, + ) + .expect("test session"), + ) + } + + fn runtime_with_user(session: Arc) -> TransportV2Runtime { + let runtime = TransportV2Runtime::new([0x11; 32], SessionManager::new()); + runtime.set_user_for_test(session).unwrap(); + runtime + } + + #[tokio::test] + async fn local_session_capacity_exhaustion_retires_without_sending() { + let manager = SessionManager::new(); + let session = Arc::new( + V2Session::from_master_with_budgets_for_test( + uuid::Uuid::from_bytes([0x21; 16]), + [0x22; 32], + u64::MAX, + 0, + 1, + ) + .expect("capacity-limited session"), + ); + let runtime = Arc::new(TransportV2Runtime::new([0x11; 32], manager)); + runtime.set_user_for_test(Arc::clone(&session)).unwrap(); + let client = TransportV2Client { + client: Client::new(), + base_url: "http://127.0.0.1:9".to_string(), + use_mock_attestation: true, + pcr0_trust_policy: Pcr0TrustPolicy::official_for(Default::default()), + runtime: Arc::clone(&runtime), + }; + + let result = client + .send_request( + &session, + ResponseMode::Unary, + None, + None, + LogicalRequest::new( + super::super::envelope::LogicalMethod::Get, + "/v1/models", + None, + Vec::new(), + None, + ), + ) + .await; + let error = match result { + Ok(_) => panic!("local capacity must fail before network I/O"), + Err(error) => error, + }; + + assert!(matches!(error, Error::Session(_))); + assert!(runtime.user().unwrap().is_none()); + } + + #[test] + fn authenticated_session_exhaustion_retires_exact_session() { + let exhausted = session(0x22); + let runtime = runtime_with_user(Arc::clone(&exhausted)); + let body = br#"{"error":{"code":"session_exhausted","message":"Session request capacity is exhausted"}}"#; + + retire_authenticated_session_if_exhausted( + &runtime, + &exhausted, + StatusCode::SERVICE_UNAVAILABLE.as_u16(), + Some(body), + ) + .unwrap(); + assert!(runtime.user().unwrap().is_none()); + + let replacement = session(0x33); + runtime.set_user_for_test(Arc::clone(&replacement)).unwrap(); + retire_authenticated_session_if_exhausted( + &runtime, + &exhausted, + StatusCode::SERVICE_UNAVAILABLE.as_u16(), + Some(body), + ) + .unwrap(); + assert!(Arc::ptr_eq( + &runtime.user().unwrap().expect("replacement remains"), + &replacement, + )); + } + + #[test] + fn late_stream_transport_failure_retires_only_the_failed_session() { + let runtime = TransportV2Runtime::new([0x11; 32], SessionManager::new()); + let failed = session(0x22); + runtime.set_user_for_test(Arc::clone(&failed)).unwrap(); + + let result = retire_session_on_failed_stream_item( + &runtime, + &failed, + Err(Error::InvalidResponse("truncated stream".to_string())), + ); + assert!(result.is_err()); + assert!(runtime.user().unwrap().is_none()); + + let replacement = session(0x33); + runtime.set_user_for_test(Arc::clone(&replacement)).unwrap(); + let result = retire_session_on_failed_stream_item( + &runtime, + &failed, + Err(Error::InvalidResponse("late failed stream".to_string())), + ); + assert!(result.is_err()); + assert!(Arc::ptr_eq( + &runtime.user().unwrap().expect("replacement remains"), + &replacement, + )); + } + + #[test] + fn authenticated_stream_error_preserves_the_bound_session() { + let runtime = TransportV2Runtime::new([0x11; 32], SessionManager::new()); + let session = session(0x22); + runtime.set_user_for_test(Arc::clone(&session)).unwrap(); + + let result = retire_session_on_failed_stream_item( + &runtime, + &session, + Err(Error::Api { + status: 503, + message: "authenticated application error".to_string(), + }), + ); + assert!(result.is_err()); + assert!(Arc::ptr_eq( + &runtime.user().unwrap().expect("session remains"), + &session, + )); + } + + #[test] + fn authenticated_stream_session_exhaustion_retires_the_bound_session() { + let session = session(0x22); + let runtime = runtime_with_user(Arc::clone(&session)); + + let result = retire_session_on_failed_stream_item( + &runtime, + &session, + Err(Error::Api { + status: 503, + message: r#"{"error":{"code":"session_exhausted","message":"Session response capacity is exhausted"}}"#.to_string(), + }), + ); + assert!(result.is_err()); + assert!(runtime.user().unwrap().is_none()); + } +} diff --git a/sdk/rust/src/transport_v2/runtime.rs b/sdk/rust/src/transport_v2/runtime.rs new file mode 100644 index 000000000..2922cf728 --- /dev/null +++ b/sdk/rust/src/transport_v2/runtime.rs @@ -0,0 +1,323 @@ +use std::sync::{Arc, RwLock}; + +use tokio::sync::Mutex; +use uuid::Uuid; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::session::SessionManager; + +use super::{session::V2Session, Result, TransportV2Error}; + +#[derive(Clone, Eq, PartialEq, Zeroize, ZeroizeOnDrop)] +pub(crate) struct ApiKeyScope([u8; 32]); + +impl ApiKeyScope { + pub(crate) const fn new(fingerprint: [u8; 32]) -> Self { + Self(fingerprint) + } +} + +impl std::fmt::Debug for ApiKeyScope { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("ApiKeyScope([REDACTED])") + } +} + +struct ApiKeySession { + scope: ApiKeyScope, + session: Arc, +} + +/// Authority-scoped transport-v2 sessions owned by one SDK client. +/// +/// The short synchronous locks protect only pointer replacement. Network and +/// cryptographic work is serialized by the corresponding asynchronous gate, +/// never while a state lock is held. +pub(super) struct TransportV2Runtime { + cache_namespace_root: RwLock<[u8; 32]>, + anonymous: RwLock>>, + session_manager: SessionManager, + api_key: RwLock>, + anonymous_gate: Mutex<()>, + user_gate: Mutex<()>, + api_key_gate: Mutex<()>, +} + +impl TransportV2Runtime { + pub(super) fn new(cache_namespace_root: [u8; 32], session_manager: SessionManager) -> Self { + Self { + cache_namespace_root: RwLock::new(cache_namespace_root), + anonymous: RwLock::new(None), + session_manager, + api_key: RwLock::new(None), + anonymous_gate: Mutex::new(()), + user_gate: Mutex::new(()), + api_key_gate: Mutex::new(()), + } + } + + pub(super) fn cache_namespace_root(&self) -> Result<[u8; 32]> { + self.cache_namespace_root + .read() + .map_err(|_| TransportV2Error::SessionStateUnavailable) + .map(|root| *root) + } + + pub(super) fn replace_cache_namespace_root(&self, root: [u8; 32]) -> Result<()> { + let mut current = self + .cache_namespace_root + .write() + .map_err(|_| TransportV2Error::SessionStateUnavailable)?; + current.zeroize(); + *current = root; + drop(current); + self.clear_sessions() + } + + pub(super) fn anonymous(&self) -> Result>> { + self.anonymous + .read() + .map_err(|_| TransportV2Error::SessionStateUnavailable) + .map(|session| session.clone()) + } + + pub(super) fn set_anonymous(&self, session: Arc) -> Result<()> { + *self + .anonymous + .write() + .map_err(|_| TransportV2Error::SessionStateUnavailable)? = Some(session); + Ok(()) + } + + pub(super) fn clear_anonymous_if(&self, expected: &Arc) -> Result<()> { + let mut session = self + .anonymous + .write() + .map_err(|_| TransportV2Error::SessionStateUnavailable)?; + if session + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, expected)) + { + *session = None; + } + Ok(()) + } + + pub(super) fn user(&self) -> Result>> { + self.session_manager + .get_user_session() + .map_err(|_| TransportV2Error::SessionStateUnavailable) + } + + pub(super) fn clear_user(&self) -> Result<()> { + self.session_manager + .clear_user_session() + .map_err(|_| TransportV2Error::SessionStateUnavailable) + } + + pub(super) fn clear_user_if(&self, expected: &Arc) -> Result<()> { + self.session_manager + .clear_user_session_if(expected) + .map_err(|_| TransportV2Error::SessionStateUnavailable) + } + + pub(super) fn api_key(&self, scope: &ApiKeyScope) -> Result>> { + self.api_key + .read() + .map_err(|_| TransportV2Error::SessionStateUnavailable) + .map(|entry| { + entry + .as_ref() + .filter(|entry| &entry.scope == scope) + .map(|entry| Arc::clone(&entry.session)) + }) + } + + pub(super) fn set_api_key(&self, scope: &ApiKeyScope, session: Arc) -> Result<()> { + *self + .api_key + .write() + .map_err(|_| TransportV2Error::SessionStateUnavailable)? = Some(ApiKeySession { + scope: scope.clone(), + session, + }); + Ok(()) + } + + pub(super) fn clear_api_key(&self) -> Result<()> { + *self + .api_key + .write() + .map_err(|_| TransportV2Error::SessionStateUnavailable)? = None; + Ok(()) + } + + pub(super) fn clear_api_key_if( + &self, + scope: &ApiKeyScope, + expected: &Arc, + ) -> Result<()> { + let mut entry = self + .api_key + .write() + .map_err(|_| TransportV2Error::SessionStateUnavailable)?; + if entry.as_ref().is_some_and(|current| { + ¤t.scope == scope && Arc::ptr_eq(¤t.session, expected) + }) { + *entry = None; + } + Ok(()) + } + + /// Forget any cached authority slot that still references a failed + /// session. This never retries the request; it only lets a later, + /// independently initiated call establish fresh attested state. + pub(super) fn clear_session_if(&self, expected: &Arc) -> Result<()> { + self.clear_anonymous_if(expected)?; + self.clear_user_if(expected)?; + let mut entry = self + .api_key + .write() + .map_err(|_| TransportV2Error::SessionStateUnavailable)?; + if entry + .as_ref() + .is_some_and(|current| Arc::ptr_eq(¤t.session, expected)) + { + *entry = None; + } + Ok(()) + } + + pub(super) fn clear_sessions(&self) -> Result<()> { + *self + .anonymous + .write() + .map_err(|_| TransportV2Error::SessionStateUnavailable)? = None; + self.clear_user()?; + self.clear_api_key() + } + + pub(super) fn session_manager(&self) -> SessionManager { + self.session_manager.clone() + } + + #[cfg(test)] + pub(super) fn set_user_for_test(&self, session: Arc) -> Result<()> { + let expected = self + .session_manager + .get_credential_snapshot() + .map_err(|_| TransportV2Error::SessionStateUnavailable)? + .auth_epoch; + self.session_manager + .replace_user_tokens_and_session_if_epoch( + &expected, + "test-access".to_string(), + Some("test-refresh".to_string()), + "test-user".to_string(), + session, + ) + .map_err(|_| TransportV2Error::SessionStateUnavailable)? + .ok_or(TransportV2Error::SessionStateUnavailable) + .map(|_| ()) + } + + pub(super) fn active_session_id(&self) -> Result> { + if let Some(session) = self.anonymous()? { + return Ok(Some(session.session_id())); + } + if let Some(session) = self.user()? { + return Ok(Some(session.session_id())); + } + let api_key = self + .api_key + .read() + .map_err(|_| TransportV2Error::SessionStateUnavailable)?; + Ok(api_key.as_ref().map(|entry| entry.session.session_id())) + } + + pub(super) const fn anonymous_gate(&self) -> &Mutex<()> { + &self.anonymous_gate + } + + pub(super) const fn user_gate(&self) -> &Mutex<()> { + &self.user_gate + } + + pub(super) const fn api_key_gate(&self) -> &Mutex<()> { + &self.api_key_gate + } +} + +impl Drop for TransportV2Runtime { + fn drop(&mut self) { + if let Ok(root) = self.cache_namespace_root.get_mut() { + root.zeroize(); + } + } +} + +impl std::fmt::Debug for TransportV2Runtime { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("TransportV2Runtime") + .field("cache_namespace_root", &"[REDACTED]") + .field("anonymous", &"[SESSION SLOT]") + .field("user", &"[AUTH LIFECYCLE SESSION SLOT]") + .field("api_key", &"[SESSION SLOT]") + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session(marker: u8) -> Arc { + Arc::new( + V2Session::from_master_for_test(Uuid::from_bytes([marker; 16]), [marker; 32], u64::MAX) + .expect("test session"), + ) + } + + #[test] + fn failed_session_cleanup_is_pointer_scoped_across_authorities() { + let manager = SessionManager::new(); + let runtime = TransportV2Runtime::new([0x11; 32], manager.clone()); + let failed = session(0x22); + let replacement = session(0x33); + let scope = ApiKeyScope::new([0x44; 32]); + + runtime.set_anonymous(Arc::clone(&failed)).unwrap(); + manager + .replace_user_tokens_and_session_if_epoch( + &manager.get_credential_snapshot().unwrap().auth_epoch, + "access".to_string(), + Some("refresh".to_string()), + "user".to_string(), + Arc::clone(&failed), + ) + .unwrap() + .expect("install failed session"); + runtime.set_api_key(&scope, Arc::clone(&failed)).unwrap(); + runtime.clear_session_if(&failed).unwrap(); + assert!(runtime.anonymous().unwrap().is_none()); + assert!(runtime.user().unwrap().is_none()); + assert!(runtime.api_key(&scope).unwrap().is_none()); + + manager + .replace_user_tokens_and_session_if_epoch( + &manager.get_credential_snapshot().unwrap().auth_epoch, + "new-access".to_string(), + Some("new-refresh".to_string()), + "user".to_string(), + Arc::clone(&replacement), + ) + .unwrap() + .expect("install replacement"); + runtime.clear_session_if(&failed).unwrap(); + assert!(Arc::ptr_eq( + &runtime.user().unwrap().expect("replacement remains"), + &replacement, + )); + } +} diff --git a/sdk/rust/src/transport_v2/session.rs b/sdk/rust/src/transport_v2/session.rs index 68171f56c..e77a3f436 100644 --- a/sdk/rust/src/transport_v2/session.rs +++ b/sdk/rust/src/transport_v2/session.rs @@ -19,9 +19,9 @@ use super::{ decrypt_key_exchange_record, DirectionalKeys, SessionMaster, KEY_LEN, MIN_RECORD_LEN, }, envelope::{ - CacheNamespaceRoot, Credential, EncodedBytes, EncryptedOuterRecord, EnvelopeLimits, - LogicalRequest, RequestEnvelope, RequestId, ResponseMode, UnaryResponseEnvelope, Version2, - MAX_KEY_EXCHANGE_BYTES, MAX_OUTER_REQUEST_BYTES, + check_limit, CacheNamespaceRoot, Credential, EncodedBytes, EnvelopeLimits, LogicalRequest, + RequestEnvelope, RequestId, ResponseMode, UnaryResponseEnvelope, Version2, + MAX_KEY_EXCHANGE_BYTES, MAX_OUTER_REQUEST_BYTES, MAX_OUTER_RESPONSE_BYTES, }, stream::StreamDecoder, Result, TransportV2Error, @@ -169,7 +169,7 @@ fn parse_canonical_session_id(encoded: &str) -> Result { } /// Exact crypto context for one attested transport-v2 session. -pub(super) struct V2Session { +pub(crate) struct V2Session { session_id: Uuid, expires_at_unix_seconds: u64, keys: Arc, @@ -314,7 +314,7 @@ impl V2Session { } #[cfg(test)] - pub(super) fn from_master_for_test( + pub(crate) fn from_master_for_test( session_id: Uuid, session_master: [u8; KEY_LEN], expires_at_unix_seconds: u64, @@ -343,14 +343,23 @@ impl V2Session { ) } - pub(super) const fn session_id(&self) -> Uuid { + pub(crate) const fn session_id(&self) -> Uuid { self.session_id } + #[cfg(test)] pub(super) const fn expires_at_unix_seconds(&self) -> u64 { self.expires_at_unix_seconds } + pub(crate) fn is_expired(&self) -> Result { + let now_unix_seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| TransportV2Error::SessionExpired)? + .as_secs(); + Ok(now_unix_seconds >= self.expires_at_unix_seconds) + } + pub(super) fn prepare_request( &self, response_mode: ResponseMode, @@ -420,10 +429,12 @@ impl V2Session { let encrypted = self .keys .encrypt_request_record(&self.session_id, &plaintext)?; - let outer_body = EncryptedOuterRecord { - encrypted: EncodedBytes::from_bytes(encrypted), - } - .to_json_vec(MAX_OUTER_REQUEST_BYTES)?; + check_limit( + encrypted.len(), + MAX_OUTER_REQUEST_BYTES, + "encrypted request", + )?; + let outer_body = encrypted; Ok(PreparedRequest { session_id: self.session_id, @@ -504,6 +515,7 @@ impl PreparedRequest { self.session_id } + #[cfg(test)] pub(super) const fn request_id(&self) -> RequestId { self.request_id } @@ -571,12 +583,16 @@ impl ResponseContext { } fn decrypt_unary_envelope(&self, outer_body: &[u8]) -> Result { - let outer = EncryptedOuterRecord::from_json_slice(outer_body, MAX_OUTER_REQUEST_BYTES)?; - let encrypted_limit = EnvelopeLimits::DEFAULT + check_limit( + outer_body.len(), + MAX_OUTER_RESPONSE_BYTES, + "encrypted response", + )?; + let encrypted_limit = EnvelopeLimits::RESPONSE .envelope_bytes .checked_add(MIN_RECORD_LEN) .ok_or(TransportV2Error::InvalidResponse)?; - if outer.encrypted.len() > encrypted_limit { + if outer_body.len() > encrypted_limit { return Err(TransportV2Error::LimitExceeded { field: "encrypted response", limit: encrypted_limit, @@ -585,10 +601,10 @@ impl ResponseContext { let plaintext = Zeroizing::new(self.keys.decrypt_unary_response_record( &self.session_id, &self.request_id, - outer.encrypted.as_slice(), + outer_body, )?); let response = - UnaryResponseEnvelope::from_json_slice(&plaintext, &EnvelopeLimits::DEFAULT)?; + UnaryResponseEnvelope::from_json_slice(&plaintext, &EnvelopeLimits::RESPONSE)?; if response.request_id != self.request_id { return Err(TransportV2Error::BindingMismatch); } diff --git a/sdk/rust/src/transport_v2/stream.rs b/sdk/rust/src/transport_v2/stream.rs index f4fdc28f1..f9a2a1a00 100644 --- a/sdk/rust/src/transport_v2/stream.rs +++ b/sdk/rust/src/transport_v2/stream.rs @@ -21,7 +21,7 @@ const MAX_STREAM_PLAINTEXT_BYTES: usize = 128 * 1024; const MAX_STREAM_ENCRYPTED_BYTES: usize = MAX_STREAM_PLAINTEXT_BYTES + MIN_RECORD_LEN; const MAX_STREAM_BASE64_BYTES: usize = 4 * MAX_STREAM_ENCRYPTED_BYTES.div_ceil(3); const MAX_STREAM_CARRIER_FRAME_BYTES: usize = b"data: ".len() + MAX_STREAM_BASE64_BYTES + 2; -const MAX_LOGICAL_STREAM_BYTES: usize = 64 * 1024 * 1024; +const MAX_LOGICAL_STREAM_BYTES: usize = EnvelopeLimits::RESPONSE.logical_body_bytes; #[derive(Eq, PartialEq)] pub(super) enum StreamEvent { @@ -205,7 +205,7 @@ impl StreamDecoder { limit: MAX_STREAM_PLAINTEXT_BYTES, }); } - let record = StreamRecord::from_json_slice(&plaintext, &EnvelopeLimits::DEFAULT)?; + let record = StreamRecord::from_json_slice(&plaintext, &EnvelopeLimits::RESPONSE)?; if record.request_id() != &self.request_id || record.sequence() != expected_sequence { return Err(TransportV2Error::BindingMismatch); } diff --git a/sdk/rust/src/transport_v2/tests.rs b/sdk/rust/src/transport_v2/tests.rs index 95ae9fa63..e7f44b3ea 100644 --- a/sdk/rust/src/transport_v2/tests.rs +++ b/sdk/rust/src/transport_v2/tests.rs @@ -17,9 +17,9 @@ use super::{ }, envelope::{ encode_canonical_opaque_path_segment, CacheNamespaceRoot, Credential, EncodedBytes, - EncryptedOuterRecord, EnvelopeLimits, HeaderField, LogicalMethod, LogicalRequest, - RequestEnvelope, RequestId, ResponseMode, StreamRecord, UnaryResponseEnvelope, Version2, - MAX_OUTER_REQUEST_BYTES, MAX_STREAM_CHUNK_BYTES, + EnvelopeLimits, HeaderField, LogicalMethod, LogicalRequest, RequestEnvelope, RequestId, + ResponseMode, StreamRecord, UnaryResponseEnvelope, Version2, MAX_OUTER_REQUEST_BYTES, + MAX_OUTER_RESPONSE_BYTES, MAX_STREAM_CHUNK_BYTES, }, session::{KeyExchangeCompletion, PreparedKeyExchange, V2Session}, stream::{max_stream_carrier_frame_bytes_for_test, StreamDecoder, StreamEvent}, @@ -71,7 +71,6 @@ struct DirectionalVector { #[derive(Deserialize)] struct RecordVector { aad_hex: String, - nonce_hex: String, plaintext_utf8: String, plaintext_hex: String, record_hex: String, @@ -404,6 +403,91 @@ fn credentials_and_cache_root_use_exact_non_null_wire_shapes() { } } +#[test] +fn unary_carrier_limits_admit_exact_logical_boundaries_and_reject_one_byte_more() { + let request_limit = EnvelopeLimits::DEFAULT.logical_body_bytes; + let session_id = Uuid::nil(); + let master_bytes = [0x33; 32]; + let request_id = RequestId::from_bytes([0x34; 16]); + let session = + V2Session::from_master_for_test(session_id, master_bytes, u64::MAX).expect("session"); + + let prepared = session + .prepare_request_for_test( + (0, request_id), + ResponseMode::Unary, + None, + None, + LogicalRequest::new( + LogicalMethod::Post, + "/v1/chat/completions", + None, + vec![], + Some(vec![0_u8; request_limit]), + ), + ) + .expect("exact 50 MiB logical request body"); + let (outer_request, _) = prepared.into_parts(); + assert!(outer_request.len() <= MAX_OUTER_REQUEST_BYTES); + assert!(outer_request.len() >= MIN_RECORD_LEN); + drop(outer_request); + + assert_eq!( + LogicalRequest::new( + LogicalMethod::Post, + "/v1/chat/completions", + None, + vec![], + Some(vec![0_u8; request_limit + 1]), + ) + .validate(&EnvelopeLimits::DEFAULT) + .expect_err("request body over 50 MiB"), + TransportV2Error::LimitExceeded { + field: "logical body", + limit: request_limit, + } + ); + + let response_limit = EnvelopeLimits::RESPONSE.logical_body_bytes; + let response = UnaryResponseEnvelope { + version: Version2, + request_id, + status: 200, + headers: vec![], + body_base64: Some(EncodedBytes::from_bytes(vec![0_u8; response_limit])), + }; + response + .validate(&EnvelopeLimits::RESPONSE) + .expect("exact 28 MiB logical response body"); + let plaintext = serde_json::to_vec(&response).expect("response JSON"); + assert!(plaintext.len() <= EnvelopeLimits::RESPONSE.envelope_bytes); + let keys = DirectionalKeys::derive(&SessionMaster::from_bytes(master_bytes)).expect("keys"); + let outer_response = keys + .encrypt_unary_response_record_for_test(&session_id, &request_id, &plaintext) + .expect("raw response record"); + assert_eq!(outer_response.len(), plaintext.len() + MIN_RECORD_LEN); + assert!(outer_response.len() <= MAX_OUTER_RESPONSE_BYTES); + drop(outer_response); + drop(plaintext); + drop(response); + + assert_eq!( + UnaryResponseEnvelope { + version: Version2, + request_id, + status: 200, + headers: vec![], + body_base64: Some(EncodedBytes::from_bytes(vec![0_u8; response_limit + 1])), + } + .validate(&EnvelopeLimits::RESPONSE) + .expect_err("response body over 28 MiB"), + TransportV2Error::LimitExceeded { + field: "logical body", + limit: response_limit, + } + ); +} + #[test] fn opaque_item_paths_preserve_released_rust_sdk_encoding_and_are_method_aware() { for (value, encoded) in [ @@ -755,9 +839,7 @@ fn prepared_requests_are_exact_session_bound_and_reject_reserved_auto_mode() { let (outer_body, _) = prepared.into_parts(); assert!(outer_body.len() <= MAX_OUTER_REQUEST_BYTES); - let outer = EncryptedOuterRecord::from_json_slice(&outer_body, MAX_OUTER_REQUEST_BYTES) - .expect("strict outer request"); - assert!(outer.encrypted.len() >= MIN_RECORD_LEN); + assert!(outer_body.len() >= MIN_RECORD_LEN); } #[test] @@ -852,13 +934,8 @@ fn sessions_enforce_request_id_uniqueness_and_request_record_budgets() { body_base64: None, }; let plaintext = serde_json::to_vec(&response).expect("response JSON"); - let record = keys - .encrypt_unary_response_record_for_test(&session_id, &request_id, &plaintext) - .expect("response record"); - serde_json::to_vec(&EncryptedOuterRecord { - encrypted: EncodedBytes::from_bytes(record), - }) - .expect("outer response") + keys.encrypt_unary_response_record_for_test(&session_id, &request_id, &plaintext) + .expect("response record") }; let (_, first_context) = first.into_parts(); first_context @@ -956,10 +1033,7 @@ fn authenticated_stream_pre_start_error_releases_the_unused_terminal_slot() { let record = keys .encrypt_unary_response_record_for_test(&session_id, &stream_request_id, &plaintext) .expect("response record"); - let outer = serde_json::to_vec(&EncryptedOuterRecord { - encrypted: EncodedBytes::from_bytes(record), - }) - .expect("outer response"); + let outer = record; let (_, context) = stream.into_parts(); context .decrypt_stream_pre_start_error_outer(&outer) @@ -1034,10 +1108,7 @@ fn unary_response_requires_exact_aad_and_inner_request_id() { let record = keys .encrypt_unary_response_record_for_test(&session_id, &request_id, &plaintext) .expect("response record"); - let outer = serde_json::to_vec(&EncryptedOuterRecord { - encrypted: EncodedBytes::from_bytes(record), - }) - .expect("outer response"); + let outer = record; let response = response_context(session_id, master_bytes, request_id, ResponseMode::Unary) .decrypt_unary_outer(&outer) .expect("authenticated unary response"); @@ -1056,10 +1127,7 @@ fn unary_response_requires_exact_aad_and_inner_request_id() { let record = keys .encrypt_unary_response_record_for_test(&session_id, &request_id, &plaintext) .expect("response record"); - let outer = serde_json::to_vec(&EncryptedOuterRecord { - encrypted: EncodedBytes::from_bytes(record), - }) - .expect("outer response"); + let outer = record; assert_eq!( response_context(session_id, master_bytes, request_id, ResponseMode::Unary) .decrypt_unary_outer(&outer) @@ -1084,13 +1152,8 @@ fn response_context_enforces_mode_and_only_allows_pre_start_stream_errors() { body_base64: Some(EncodedBytes::from_bytes(b"redacted-error".to_vec())), }; let plaintext = serde_json::to_vec(&response).expect("response JSON"); - let record = keys - .encrypt_unary_response_record_for_test(&session_id, &request_id, &plaintext) - .expect("response record"); - serde_json::to_vec(&EncryptedOuterRecord { - encrypted: EncodedBytes::from_bytes(record), - }) - .expect("outer response") + keys.encrypt_unary_response_record_for_test(&session_id, &request_id, &plaintext) + .expect("response record") }; assert_eq!( diff --git a/sdk/src/lib/ai.ts b/sdk/src/lib/ai.ts index f6399b79b..a2c4c9e33 100644 --- a/sdk/src/lib/ai.ts +++ b/sdk/src/lib/ai.ts @@ -1,128 +1,207 @@ -import { decryptMessage, encryptMessage } from "./encryption"; -import { getAttestation, type Attestation } from "./getAttestation"; import * as api from "./api"; -import { serializePcrConfig, snapshotPcrConfig, type PcrConfig } from "./pcr"; -import { classifyRecovery } from "./recovery"; +import { snapshotPcrConfig, type PcrConfig } from "./pcr"; +import { readTransportV2Credentials } from "./transportV2/auth"; +import { canonicalizeTransportV2ApiUrl } from "./transportV2/auth"; +import { + transportV2Client, + type TransportV2Authority, + type TransportV2Client +} from "./transportV2/client"; +import type { LogicalMethod, ResponseMode } from "./transportV2/envelope"; export interface CustomFetchOptions { - /** Optional API key to use instead of a JWT token. */ + /** Optional API key to use instead of a user-bound session. */ apiKey?: string; - /** API URL used for attestation; required outside OpenSecretProvider. */ + /** Fixed API URL whose attestation policy governs every request. */ apiUrl?: string; - /** PCR0 trust policy enforced before non-loopback session key exchange; defaults to production. */ + /** PCR0 trust policy enforced before non-loopback key exchange. */ pcrConfig?: PcrConfig; } -interface ActiveAttestation { - sessionKey: Uint8Array; - sessionId: string; -} - /** @internal Exported for deterministic transport tests, not from the package entry point. */ export interface CustomFetchDependencies { - decryptMessage: typeof decryptMessage; - encryptMessage: typeof encryptMessage; - fetch: typeof globalThis.fetch; - getAttestation: typeof getAttestation; - refreshToken: typeof api.refreshToken; -} - -interface RequestSnapshot { - url: string; - headers: Headers; - options: RequestInit; - plaintextBody?: string; - signal?: AbortSignal | null; + client: Pick; + getApiPcrConfig: typeof api.getApiPcrConfig; + getApiUrl: typeof api.getApiUrl; } const defaultDependencies: CustomFetchDependencies = { - decryptMessage, - encryptMessage, - fetch: (...args) => globalThis.fetch(...args), - getAttestation, - refreshToken: api.refreshToken + client: transportV2Client, + getApiPcrConfig: () => api.getApiPcrConfig(), + getApiUrl: () => api.getApiUrl() }; -function requireActiveAttestation(attestation: Attestation): ActiveAttestation { - if (!attestation.sessionKey || !attestation.sessionId) { - throw new Error("No session key or ID available"); +const FORBIDDEN_LOGICAL_HEADERS = new Set([ + "authorization", + "user-agent", + "proxy-authorization", + "proxy-authenticate", + "cookie", + "set-cookie", + "host", + "content-length", + "content-encoding", + "content-md5", + "digest", + // Transport v2 selects unary versus streaming through the authenticated response mode. + "accept", + "accept-encoding", + "connection", + "keep-alive", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "x-session-id", + "x-api-key", + "api-key", + "x-openai-api-key", + "x-tinfoil-api-key", + "x-goog-api-key", + "x-anthropic-api-key", + "openai-organization", + "openai-project" +]); + +function logicalMethod(value: string): LogicalMethod { + const method = value.toUpperCase(); + if (!(["GET", "POST", "PUT", "PATCH", "DELETE"] as const).includes(method as LogicalMethod)) { + throw new Error(`Transport v2 does not support the ${method} method.`); } + return method as LogicalMethod; +} - return { - sessionKey: attestation.sessionKey, - sessionId: attestation.sessionId - }; +function safeLogicalHeaders(input: Headers): Headers { + const output = new Headers(); + input.forEach((value, name) => { + const normalizedName = name.toLowerCase(); + if ( + !FORBIDDEN_LOGICAL_HEADERS.has(normalizedName) && + !normalizedName.startsWith("x-stainless-") + ) { + output.append(name, value); + } + }); + return output; } -async function discardResponse(response: Response): Promise { - try { - await response.body?.cancel(); - } catch { - // The response is being discarded for a bounded retry. Some runtimes may - // already have closed its body, which needs no further cleanup. +function rejectAutomaticOpenAiRetry(headers: Headers): void { + const retryCount = headers.get("x-stainless-retry-count"); + if (retryCount !== null && retryCount !== "0") { + throw new Error( + "Transport v2 rejected an automatic OpenAI retry after a potentially sent request. Configure maxRetries: 0." + ); } } -function throwIfAborted(signal?: AbortSignal | null): void { - signal?.throwIfAborted(); +async function requestBody(normalized: Request): Promise { + if (normalized.method === "GET" || normalized.method === "HEAD" || normalized.body === null) { + return null; + } + return new Uint8Array(await normalized.arrayBuffer()); } -function allowsRequestBody(method: string): boolean { - return method !== "GET" && method !== "HEAD"; +function logicalPath(apiUrl: string, requestUrl: string): string { + const canonicalApiUrl = canonicalizeTransportV2ApiUrl(apiUrl); + const base = new URL(canonicalApiUrl); + const request = new URL(requestUrl); + const basePath = base.pathname === "/" ? "" : base.pathname.replace(/\/+$/u, ""); + if ( + request.origin !== base.origin || + (basePath && request.pathname !== basePath && !request.pathname.startsWith(`${basePath}/`)) + ) { + throw new Error("Transport v2 request escaped its attested API origin."); + } + return basePath ? request.pathname.slice(basePath.length) || "/" : request.pathname; } -async function snapshotPlaintextBody( - normalized: Request, - init?: RequestInit -): Promise { - if (!allowsRequestBody(normalized.method)) return undefined; +async function responseModeFor( + apiUrl: string, + request: Request, + body: Uint8Array | null +): Promise { + const path = logicalPath(apiUrl, request.url); + if (path === "/v1/responses") return "stream"; + if (path !== "/v1/chat/completions" || body === null) return "unary"; + try { + const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)) as { + stream?: unknown; + }; + return value.stream === true ? "stream" : "unary"; + } catch { + return "unary"; + } +} - const bodyKnownPresent = init?.body != null || normalized.body != null; - const plaintextBody = await normalized.text(); - return bodyKnownPresent || normalized.bodyUsed || plaintextBody !== "" - ? plaintextBody - : undefined; +function authorityFor(apiUrl: string, requestUrl: string, apiKey?: string): TransportV2Authority { + const path = logicalPath(apiUrl, requestUrl); + if (apiKey !== undefined) return { kind: "api_key", value: apiKey }; + const credentials = readTransportV2Credentials(apiUrl, "user"); + if (credentials) { + return { + kind: "user", + principalId: credentials.principalId, + generation: credentials.generation + }; + } + if (path === "/v1/models") { + return { kind: "anonymous", purpose: "public" }; + } + throw new Error("A fresh transport v2 sign-in or API key is required."); } -async function snapshotRequest( - input: string | URL | Request, - init?: RequestInit -): Promise { - const normalized = new Request(input, init); - const signal = - init?.signal === null - ? null - : (init?.signal ?? (input instanceof Request ? input.signal : undefined)); - const url = normalized.url; - const headers = new Headers(normalized.headers); - const options: RequestInit = { - ...init, - method: normalized.method, - cache: init?.cache ?? normalized.cache, - credentials: init?.credentials ?? normalized.credentials, - integrity: init?.integrity ?? normalized.integrity, - keepalive: init?.keepalive ?? normalized.keepalive, - mode: init?.mode ?? normalized.mode, - redirect: init?.redirect ?? normalized.redirect, - referrer: init?.referrer ?? normalized.referrer, - referrerPolicy: init?.referrerPolicy ?? normalized.referrerPolicy, - signal - }; - delete options.body; - delete options.headers; - // Firefox does not expose Request.body. Gate on the normalized method first, - // then use the plaintext and explicit init body as fallback presence signals. - const plaintextBody = await snapshotPlaintextBody(normalized, init); +function decodeCanonicalBase64(value: string): Uint8Array { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(value)) { + throw new Error("Invalid base64 audio data in TTS response"); + } + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + let canonical = ""; + for (const byte of bytes) canonical += String.fromCharCode(byte); + if (btoa(canonical) !== value) { + bytes.fill(0); + throw new Error("Invalid base64 audio data in TTS response"); + } + return bytes; +} - return { - url, - headers, - options, - plaintextBody, - signal - }; +async function restoreTtsBinary(response: Response): Promise { + if (!response.ok || response.headers.get("content-type")?.includes("text/event-stream")) { + return response; + } + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) return response; + const bytes = new Uint8Array(await response.clone().arrayBuffer()); + if (bytes.length > 50 * 1024 * 1024) return response; + try { + const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as { + content_base64?: unknown; + content_type?: unknown; + }; + if (typeof value.content_base64 !== "string" || typeof value.content_type !== "string") { + return response; + } + const audio = decodeCanonicalBase64(value.content_base64); + const headers = new Headers(response.headers); + headers.set("content-type", value.content_type); + headers.delete("content-encoding"); + headers.delete("content-length"); + headers.delete("transfer-encoding"); + return new Response(audio, { status: response.status, headers }); + } catch { + return response; + } finally { + bytes.fill(0); + } } +/** + * Creates the attested Transport V2 fetch adapter. OpenAI clients should set + * `maxRetries: 0`; nonzero Stainless retry attempts are rejected before any + * second encrypted transport request can be sent. + */ export function createCustomFetch( options?: CustomFetchOptions ): (input: string | URL | Request, init?: RequestInit) => Promise { @@ -134,331 +213,30 @@ export function createCustomFetchWithDependencies( options: CustomFetchOptions | undefined, dependencies: CustomFetchDependencies ): (input: string | URL | Request, init?: RequestInit) => Promise { - const attestationRefreshes = new Map>(); - - const resolveAttestationIdentity = () => { - const apiUrl = options?.apiUrl || api.getApiUrl() || undefined; - const pcrConfig = snapshotPcrConfig(options?.pcrConfig || api.getApiPcrConfig()); - return { - apiUrl, - pcrConfig, - scope: `${apiUrl || ""}\n${serializePcrConfig(pcrConfig)}` - }; - }; - - const renewAttestation = async ( - failedSessionId: string, - identity: ReturnType - ): Promise => { - const renewalScope = `${identity.scope}\n${failedSessionId}`; - let attestationRefresh = attestationRefreshes.get(renewalScope); - if (!attestationRefresh) { - let resolveRefresh!: (attestation: ActiveAttestation) => void; - let rejectRefresh!: (reason?: unknown) => void; - attestationRefresh = new Promise((resolve, reject) => { - resolveRefresh = resolve; - rejectRefresh = reject; - }); - attestationRefreshes.set(renewalScope, attestationRefresh); - - const registeredRefresh = attestationRefresh; - void (async () => { - try { - // A concurrent request or token refresh may already have replaced - // the failed generation. This lookup belongs inside the registered - // leader so a late caller cannot miss the in-flight renewal after - // its forced refresh evicts the cache. - const currentAttestation = requireActiveAttestation( - await dependencies.getAttestation(false, identity.apiUrl, identity.pcrConfig) - ); - const renewedAttestation = - currentAttestation.sessionId === failedSessionId - ? requireActiveAttestation( - await dependencies.getAttestation(true, identity.apiUrl, identity.pcrConfig) - ) - : currentAttestation; - resolveRefresh(renewedAttestation); - } catch (error) { - rejectRefresh(error); - } finally { - if (attestationRefreshes.get(renewalScope) === registeredRefresh) { - attestationRefreshes.delete(renewalScope); - } - } - })(); - } - - return attestationRefresh; - }; - - return async (requestUrl: string | URL | Request, init?: RequestInit): Promise => { - // Authentication mode is part of the logical request snapshot. A caller - // may retain and mutate the options object while this request is in - // flight; recovery must not switch between API-key and JWT credentials. - const apiKey = options?.apiKey; - const usesApiKey = Boolean(apiKey); - const getAuthHeader = () => { - // If an API key is provided, use it instead of JWT token - if (apiKey) { - return `Bearer ${apiKey}`; - } - - // Otherwise, use the standard JWT token - const currentAccessToken = window.localStorage.getItem("access_token"); - if (!currentAccessToken) { - throw new Error("No access token or API key available"); - } - return `Bearer ${currentAccessToken}`; - }; - + return async (input: string | URL | Request, init?: RequestInit): Promise => { + const normalized = new Request(input, init); + normalized.signal.throwIfAborted(); + rejectAutomaticOpenAiRetry(normalized.headers); + const apiUrl = options?.apiUrl || dependencies.getApiUrl(); + const pcrConfig = snapshotPcrConfig(options?.pcrConfig || dependencies.getApiPcrConfig()); + const authority = authorityFor(apiUrl, normalized.url, options?.apiKey); + const body = await requestBody(normalized); + normalized.signal.throwIfAborted(); try { - // Capture endpoint and trust policy together so retries cannot cross a - // provider reconfiguration that happens while this request is in flight. - const attestationIdentity = resolveAttestationIdentity(); - // Keep this operation bound to the identity that initiated it. An - // unrelated account change during attestation must not send the - // already-prepared plaintext request under a different token. - let authHeader = getAuthHeader(); - const request = await snapshotRequest(requestUrl, init); - throwIfAborted(request.signal); - - const makeRequest = async (attestation: ActiveAttestation) => { - const headers = new Headers(request.headers); - headers.set("Authorization", authHeader); - headers.set("x-session-id", attestation.sessionId); - - const requestOptions: RequestInit = { ...request.options, headers }; - - // Encrypt the original plaintext again for every attempt. Reusing an - // old request body with a new session ID would make recovery fail. - if ( - request.plaintextBody !== undefined && - allowsRequestBody(request.options.method ?? "GET") - ) { - const encryptedBody = dependencies.encryptMessage( - attestation.sessionKey, - request.plaintextBody - ); - requestOptions.body = JSON.stringify({ encrypted: encryptedBody }); - headers.set("Content-Type", "application/json"); - } - - return { - attestation, - response: await dependencies.fetch(request.url, requestOptions) - }; - }; - - let attestation = requireActiveAttestation( - await dependencies.getAttestation( - false, - attestationIdentity.apiUrl, - attestationIdentity.pcrConfig - ) - ); - throwIfAborted(request.signal); - let replayed = false; - let finalAttempt: Awaited>; - - while (true) { - const attempt = await makeRequest(attestation); - const recovery = classifyRecovery(attempt.response.status, attempt.response.headers); - - if (recovery === "refresh_access_token" && !usesApiKey && !replayed) { - replayed = true; - await discardResponse(attempt.response); - throwIfAborted(request.signal); - console.warn("Unauthorized, refreshing access token"); - await dependencies.refreshToken(); - throwIfAborted(request.signal); - authHeader = getAuthHeader(); - - // The encrypted refresh call may itself have replaced a stale - // attestation. Always rebuild the outer request from current state. - attestation = requireActiveAttestation( - await dependencies.getAttestation( - false, - attestationIdentity.apiUrl, - attestationIdentity.pcrConfig - ) - ); - continue; - } - - if (recovery === "renew_session" && !replayed) { - replayed = true; - await discardResponse(attempt.response); - throwIfAborted(request.signal); - console.warn("Bad Request, renewing attestation and retrying once"); - attestation = await renewAttestation(attempt.attestation.sessionId, attestationIdentity); - throwIfAborted(request.signal); - continue; - } - - finalAttempt = attempt; - break; - } - - const { response } = finalAttempt; - const { sessionKey } = finalAttempt.attestation; - - if (!response.ok) { - const errorText = await response.text(); - console.error( - "Request failed with response status:", - response.status, - " and message:", - errorText - ); - throw Object.assign( - new Error(`Request failed with status ${response.status}: ${errorText}`), - { - status: response.status, - headers: new Headers(response.headers) - } - ); - } - - // Decrypt SSE events - if (response.headers.get("content-type")?.includes("text/event-stream")) { - const reader = response.body?.getReader(); - const decoder = new TextDecoder(); - - let buffer = ""; - const stream = new ReadableStream({ - async start(controller) { - while (true) { - const { done, value } = await reader!.read(); - if (done) break; - - const chunk = decoder.decode(value); - buffer += chunk; - - let event; - while ((event = extractEvent(buffer))) { - buffer = buffer.slice(event.length); - - // Split the event into individual lines - const lines = event.split("\n"); - - for (const line of lines) { - // Handle event: lines - pass them through as-is - if (line.trim().startsWith("event: ")) { - controller.enqueue(line + "\n"); - } - // Handle data: lines - decrypt them - else if (line.trim().startsWith("data: ")) { - const data = line.slice(6).trim(); - if (data === "[DONE]") { - controller.enqueue(`data: [DONE]\n\n`); - } else { - try { - const decrypted = dependencies.decryptMessage(sessionKey, data); - - // Always enqueue the decrypted data - // Note: We don't add \n\n here because the empty line will be added separately - controller.enqueue(`data: ${decrypted}\n`); - } catch (error) { - console.error("Decryption error:", error, "Data:", data); - // Instead of sending the encrypted data, we'll skip this chunk - console.log("Skipping corrupted chunk"); - } - } - } - // Pass through empty lines - else if (line === "") { - controller.enqueue("\n"); - } - } - } - } - controller.close(); - } - }); - - return new Response(stream, { - headers: response.headers, - status: response.status, - statusText: response.statusText - }); - } - - // Decrypt regular JSON responses - const responseText = await response.text(); - try { - const responseData = JSON.parse(responseText); - - // Check if the response has an encrypted field - if (responseData.encrypted) { - const decrypted = dependencies.decryptMessage(sessionKey, responseData.encrypted); - - // Try to parse as JSON to check for TTS response format - try { - const decryptedData = JSON.parse(decrypted); - - // Check if this is a TTS response with content_base64 and content_type - if (decryptedData.content_base64 && decryptedData.content_type) { - console.log("TTS response detected with content_type:", decryptedData.content_type); - - // Decode base64 audio data to binary - let bytes: Uint8Array; - try { - const binaryString = atob(decryptedData.content_base64); - bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - } catch (e) { - console.error("Failed to decode base64 audio data:", e); - throw new Error("Invalid base64 audio data in TTS response"); - } - - console.log("Decoded audio bytes length:", bytes.length); - - // Return as a binary response with the proper content type - const headersOut = new Headers(response.headers); - headersOut.set("content-type", decryptedData.content_type); - // Remove headers that are no longer valid for the decoded response - headersOut.delete("content-encoding"); - headersOut.delete("content-length"); - headersOut.delete("transfer-encoding"); - - return new Response(bytes, { - headers: headersOut, - status: response.status, - statusText: response.statusText - }); - } - } catch { - // Not JSON, continue with regular text response - } - // Return a new Response with the decrypted data - return new Response(decrypted, { - headers: response.headers, - status: response.status, - statusText: response.statusText - }); - } - } catch { - // If it's not JSON or doesn't have encrypted field, return original response - console.log("Response is not encrypted JSON, returning as-is"); - } - - // Return the original response text as a new Response - return new Response(responseText, { - headers: response.headers, - status: response.status, - statusText: response.statusText + const response = await dependencies.client.fetch({ + apiUrl, + pcrConfig, + url: normalized.url, + method: logicalMethod(normalized.method), + headers: safeLogicalHeaders(normalized.headers), + body, + responseMode: await responseModeFor(apiUrl, normalized, body), + authority, + signal: normalized.signal }); - } catch (error) { - console.error("Error during fetch process:", error); - throw error; + return restoreTtsBinary(response); + } finally { + body?.fill(0); } }; } - -function extractEvent(buffer: string): string | null { - const eventEnd = buffer.indexOf("\n\n"); - if (eventEnd === -1) return null; - return buffer.slice(0, eventEnd + 2); -} diff --git a/sdk/src/lib/api.ts b/sdk/src/lib/api.ts index cb41a9f2a..766473600 100644 --- a/sdk/src/lib/api.ts +++ b/sdk/src/lib/api.ts @@ -2,6 +2,9 @@ import { encode } from "@stablelib/base64"; import { authenticatedApiCall, encryptedApiCall, openAiAuthenticatedApiCall } from "./encryptedApi"; import type { Model } from "openai/resources/models.js"; import { snapshotPcrConfig, type PcrConfig } from "./pcr"; +import { readTransportV2Credentials } from "./transportV2/auth"; +import { transportV2Client } from "./transportV2/client"; +import { encodeCanonicalOpaquePathSegment } from "./transportV2/encoding"; let apiUrl = ""; let apiPcrConfig: PcrConfig = snapshotPcrConfig(); @@ -49,13 +52,99 @@ type CredentialUpdateResponse = { refresh_token?: string; }; -function storeAuthTokens(response: CredentialUpdateResponse) { - if (response.access_token) { - window.localStorage.setItem("access_token", response.access_token); +export type NativeHandoffGrantResponse = { + grant: string; + expires_at: number; +}; + +type NativeHandoffGrantRequest = { + native_session_id: string; + native_attempt_id: string; +}; + +type NativeHandoffGrantCall = ( + url: string, + method: string, + data: NativeHandoffGrantRequest, + errorMessage: string +) => Promise; + +const CANONICAL_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const NIL_UUID = "00000000-0000-0000-0000-000000000000"; +const COMPACT_BASE64URL_JWT_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; +const MAX_NATIVE_HANDOFF_GRANT_BYTES = 4096; + +function requireCanonicalUuid(value: string, fieldName: string): void { + if (!CANONICAL_UUID_PATTERN.test(value) || value === NIL_UUID) { + throw new Error(`${fieldName} must be a non-nil canonical lowercase UUID.`); + } +} + +function parseNativeHandoffGrantResponse(value: unknown): NativeHandoffGrantResponse { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Native handoff grant response is invalid."); } - if (response.refresh_token) { - window.localStorage.setItem("refresh_token", response.refresh_token); + const record = value as Record; + const keys = Object.keys(record).sort(); + if (keys.length !== 2 || keys[0] !== "expires_at" || keys[1] !== "grant") { + throw new Error("Native handoff grant response is invalid."); } + if ( + typeof record.grant !== "string" || + record.grant.length > MAX_NATIVE_HANDOFF_GRANT_BYTES || + !COMPACT_BASE64URL_JWT_PATTERN.test(record.grant) + ) { + throw new Error("Native handoff grant response contains an invalid grant."); + } + if ( + typeof record.expires_at !== "number" || + !Number.isSafeInteger(record.expires_at) || + record.expires_at < 0 + ) { + throw new Error("Native handoff grant response contains an invalid expiry."); + } + return { grant: record.grant, expires_at: record.expires_at }; +} + +/** + * Mints a short-lived native handoff grant for an already authenticated user. + * + * The grant is bound by the backend to the native app's pre-established, + * attested transport-v2 session. This hosted-browser helper only mints the + * grant; native redemption is intentionally outside the TypeScript SDK. + */ +export async function mintNativeHandoffGrant( + nativeSessionId: string, + nativeAttemptId: string +): Promise { + return mintNativeHandoffGrantWithDependencies( + nativeSessionId, + nativeAttemptId, + apiUrl, + (url, method, data, errorMessage) => + authenticatedApiCall(url, method, data, errorMessage) + ); +} + +/** @internal Exported for deterministic request/response contract tests. */ +export async function mintNativeHandoffGrantWithDependencies( + nativeSessionId: string, + nativeAttemptId: string, + configuredApiUrl: string, + call: NativeHandoffGrantCall +): Promise { + requireCanonicalUuid(nativeSessionId, "nativeSessionId"); + requireCanonicalUuid(nativeAttemptId, "nativeAttemptId"); + const response = await call( + `${configuredApiUrl}/auth/native-handoff/grant`, + "POST", + { + native_session_id: nativeSessionId, + native_attempt_id: nativeAttemptId + }, + "Failed to mint native handoff grant" + ); + return parseNativeHandoffGrantResponse(response); } export type KVListItem = { @@ -130,23 +219,24 @@ export async function fetchGuestSignUp( } export async function refreshToken(): Promise { - const refresh_token = window.localStorage.getItem("refresh_token"); - if (!refresh_token) throw new Error("No refresh token available"); - - const refreshData = { refresh_token }; + if (!readTransportV2Credentials(apiUrl, "user")) { + throw new Error("A fresh transport v2 sign-in is required."); + } try { - const response = await encryptedApiCall( - `${apiUrl}/refresh`, - "POST", - refreshData, - undefined, - "Failed to refresh token" - ); - - window.localStorage.setItem("access_token", response.access_token); - window.localStorage.setItem("refresh_token", response.refresh_token); - return response; + const response = await transportV2Client.refresh(apiUrl, "user", apiPcrConfig); + const value = (await response.json()) as Partial & { message?: unknown }; + if (!response.ok) { + throw new Error( + typeof value.message === "string" + ? value.message + : `Failed to refresh token: ${response.status}` + ); + } + if (typeof value.access_token !== "string" || typeof value.refresh_token !== "string") { + throw new Error("Transport v2 refresh returned invalid credentials."); + } + return { access_token: value.access_token, refresh_token: value.refresh_token }; } catch (error) { console.error("Error refreshing token:", error); throw error; @@ -164,7 +254,7 @@ export async function fetchUser(): Promise { export async function fetchPut(key: string, value: string): Promise { return authenticatedApiCall( - `${apiUrl}/protected/kv/${key}`, + `${apiUrl}/protected/kv/${encodeCanonicalOpaquePathSegment(key)}`, "PUT", value, "Failed to put key-value pair" @@ -173,7 +263,7 @@ export async function fetchPut(key: string, value: string): Promise { export async function fetchDelete(key: string): Promise { return authenticatedApiCall( - `${apiUrl}/protected/kv/${key}`, + `${apiUrl}/protected/kv/${encodeCanonicalOpaquePathSegment(key)}`, "DELETE", undefined, "Failed to delete key-value pair" @@ -192,7 +282,7 @@ export async function fetchDeleteAllKV(): Promise { export async function fetchGet(key: string): Promise { try { const data = await authenticatedApiCall( - `${apiUrl}/protected/kv/${key}`, + `${apiUrl}/protected/kv/${encodeCanonicalOpaquePathSegment(key)}`, "GET", undefined, "Failed to get key-value pair" @@ -318,13 +408,12 @@ export async function changePassword(currentPassword: string, newPassword: strin current_password: currentPassword, new_password: newPassword }; - const response = await authenticatedApiCall( + await authenticatedApiCall( `${apiUrl}/protected/change_password`, "POST", changePasswordData, "Failed to change password" ); - storeAuthTokens(response); } export async function initiateGitHubAuth( @@ -1203,7 +1292,7 @@ export type ModelCatalogResponse = { export async function fetchModels(apiKey?: string): Promise { try { const hasIdentityCredential = - apiKey !== undefined || window.localStorage.getItem("access_token") !== null; + apiKey !== undefined || readTransportV2Credentials(apiUrl, "user") !== null; const response = hasIdentityCredential ? await openAiAuthenticatedApiCall( `${apiUrl}/v1/models`, @@ -1414,8 +1503,7 @@ export async function listApiKeys(): Promise<{ keys: ApiKeyListResponse }> { * ``` */ export async function deleteApiKey(name: string): Promise { - // URL-encode the name to handle special characters - const encodedName = encodeURIComponent(name); + const encodedName = encodeCanonicalOpaquePathSegment(name); return authenticatedApiCall( `${apiUrl}/protected/api-keys/${encodedName}`, "DELETE", @@ -2045,7 +2133,7 @@ export type ResponsesCreateRequest = { * * NOTE: Prefer using the OpenAI client directly for conversation operations: * ```typescript - * const openai = new OpenAI({ fetch: customFetch }); + * const openai = new OpenAI({ fetch: customFetch, maxRetries: 0 }); * const conversation = await openai.conversations.create({ * metadata: { title: "Product Support", category: "technical" } * }); diff --git a/sdk/src/lib/attestation.ts b/sdk/src/lib/attestation.ts index f6160e940..7a6fdcb3b 100644 --- a/sdk/src/lib/attestation.ts +++ b/sdk/src/lib/attestation.ts @@ -268,6 +268,20 @@ async function fakeAuthenticate( return zodParsed; } +/** @internal Verifies one already-fetched attestation document for its exact endpoint mode. */ +export async function verifyAttestationDocument( + attestationDocumentBase64: string, + nonce: string, + apiUrl: string +): Promise { + if (isLocalDevelopmentApiUrl(apiUrl)) { + console.log("DEV MODE: Using fake attestation document"); + const fakeDocument = await fakeAuthenticate(attestationDocumentBase64); + return fakeDocument as AttestationDocument; + } + return authenticate(attestationDocumentBase64, awsRootCertDer, nonce); +} + export async function verifyAttestation( nonce: string, explicitApiUrl?: string @@ -279,16 +293,8 @@ export async function verifyAttestation( // First check explicit URL, then check both possible APIs const apiUrl = explicitApiUrl || getApiUrl(); - // With a local backend we get a fake attestation document, so we'll just pretend to authenticate it - if (apiUrl && isLocalDevelopmentApiUrl(apiUrl)) { - console.log("DEV MODE: Using fake attestation document"); - const fakeDocument = await fakeAuthenticate(attestationDocumentBase64); - return fakeDocument as AttestationDocument; - } - - // The real thing! - const verifiedDocument = await authenticate(attestationDocumentBase64, awsRootCertDer, nonce); - return verifiedDocument; + if (!apiUrl) throw new Error("Attestation requires a configured API URL."); + return verifyAttestationDocument(attestationDocumentBase64, nonce, apiUrl); } catch (error) { if (error instanceof Error) { console.error("Error verifying attestation document:", error); diff --git a/sdk/src/lib/developer.tsx b/sdk/src/lib/developer.tsx index 2e2484a9f..04d2dc7e6 100644 --- a/sdk/src/lib/developer.tsx +++ b/sdk/src/lib/developer.tsx @@ -1,7 +1,7 @@ -import React, { createContext, useState, useEffect } from "react"; +import React, { createContext, useState, useEffect, useRef } from "react"; import * as platformApi from "./platformApi"; import { setPlatformApiUrl } from "./platformApi"; -import { clearAttestationSessions, getAttestation } from "./getAttestation"; +import { clearAttestationSessions } from "./getAttestation"; import { authenticate } from "./attestation"; import { parseAttestationForView, @@ -11,6 +11,13 @@ import { } from "./attestationForView"; import type { AttestationDocument } from "./attestation"; import { PcrConfig } from "./pcr"; +import { + clearLegacyTransportV1Credentials, + readTransportV2Credentials, + snapshotTransportV2Auth, + subscribeTransportV2AuthInvalidation +} from "./transportV2/auth"; +import { transportV2Client, type TransportV2SessionInfo } from "./transportV2/client"; const DEFAULT_PCR_CONFIG: PcrConfig = { environment: "production" }; import type { @@ -53,7 +60,7 @@ export type OpenSecretDeveloperContextType = { * * * - Calls the login API endpoint - * - Stores access_token and refresh_token in localStorage + * - Installs origin-scoped transport v2 platform resumption descriptors * - Updates the developer state with user information * - Throws an error if authentication fails */ @@ -145,7 +152,7 @@ export type OpenSecretDeveloperContextType = { * * * - Calls the registration API endpoint - * - Stores access_token and refresh_token in localStorage + * - Installs origin-scoped transport v2 platform resumption descriptors * - Updates the developer state with new user information * - Throws an error if account creation fails */ @@ -160,8 +167,8 @@ export type OpenSecretDeveloperContextType = { * Signs out the current developer by removing authentication tokens * * - * - Calls the logout API endpoint with the current refresh_token - * - Removes access_token, refresh_token from localStorage + * - Presents the resumption credential inside the encrypted logout request + * - Clears origin-scoped transport v2 platform credentials * - Resets the developer state to show no user is authenticated */ signOut: () => Promise; @@ -187,7 +194,11 @@ export type OpenSecretDeveloperContextType = { /** * Gets an attested session after enforcing the effective PCR0 trust policy */ - getAttestation: typeof getAttestation; + getAttestation: ( + forceRefresh?: boolean, + explicitApiUrl?: string, + explicitPcrConfig?: PcrConfig + ) => Promise; /** * Authenticates an attestation document @@ -469,7 +480,9 @@ export const OpenSecretDeveloperContext = createContext { + throw new Error("getAttestation called outside of OpenSecretDeveloper"); + }, authenticate, parseAttestationForView, awsRootCertDer: AWS_ROOT_CERT_DER, @@ -536,6 +549,7 @@ export function OpenSecretDeveloper({ loading: true, developer: undefined }); + const authViewGeneration = useRef(0); useEffect(() => { if (!apiUrl || apiUrl.trim() === "") { @@ -544,6 +558,7 @@ export function OpenSecretDeveloper({ ); } setPlatformApiUrl(apiUrl, pcrConfig); + clearAttestationSessions(); // Configure the apiConfig service with the platform URL // Using dynamic import to avoid circular dependencies @@ -561,9 +576,10 @@ export function OpenSecretDeveloper({ }, [apiUrl, pcrConfig]); async function fetchDeveloper() { - const access_token = window.localStorage.getItem("access_token"); - const refresh_token = window.localStorage.getItem("refresh_token"); - if (!access_token || !refresh_token) { + const viewGeneration = ++authViewGeneration.current; + const expected = snapshotTransportV2Auth(apiUrl, "platform"); + if (expected.principalId === null) { + clearLegacyTransportV1Credentials(); setAuth({ loading: false, developer: undefined @@ -573,6 +589,14 @@ export function OpenSecretDeveloper({ try { const response = await platformApi.platformMe(); + const current = snapshotTransportV2Auth(apiUrl, "platform"); + if ( + authViewGeneration.current !== viewGeneration || + current.principalId !== expected.principalId || + response.user.id !== current.principalId + ) { + return; + } setAuth({ loading: false, developer: { @@ -582,6 +606,15 @@ export function OpenSecretDeveloper({ }); } catch (error) { console.error("Failed to fetch developer:", error); + const current = snapshotTransportV2Auth(apiUrl, "platform"); + if ( + authViewGeneration.current !== viewGeneration || + (current.principalId !== null && + (current.principalId !== expected.principalId || + current.generation !== expected.generation)) + ) { + return; + } setAuth({ loading: false, developer: undefined @@ -591,7 +624,7 @@ export function OpenSecretDeveloper({ const getAttestationDocument = async () => { const nonce = window.crypto.randomUUID(); - const response = await fetch(`${apiUrl}/attestation/${nonce}`); + const response = await fetch(`${apiUrl}/v2/attestation/${nonce}`); if (!response.ok) { throw new Error("Failed to fetch attestation document"); } @@ -607,13 +640,20 @@ export function OpenSecretDeveloper({ useEffect(() => { fetchDeveloper(); - }, []); + }, [apiUrl, pcrConfig]); + + useEffect( + () => + subscribeTransportV2AuthInvalidation(apiUrl, "platform", () => { + authViewGeneration.current += 1; + setAuth({ loading: false, developer: undefined }); + }), + [apiUrl] + ); async function signIn(email: string, password: string) { try { const { access_token, refresh_token } = await platformApi.platformLogin(email, password); - window.localStorage.setItem("access_token", access_token); - window.localStorage.setItem("refresh_token", refresh_token); await fetchDeveloper(); return { access_token, refresh_token, id: "", email }; } catch (error) { @@ -630,8 +670,6 @@ export function OpenSecretDeveloper({ invite_code, name ); - window.localStorage.setItem("access_token", access_token); - window.localStorage.setItem("refresh_token", refresh_token); await fetchDeveloper(); return { access_token, refresh_token, id: "", email, name }; } catch (error) { @@ -646,21 +684,20 @@ export function OpenSecretDeveloper({ signUp, refetchDeveloper: fetchDeveloper, signOut: async () => { - const refresh_token = window.localStorage.getItem("refresh_token"); - if (refresh_token) { - try { - await platformApi.platformLogout(refresh_token); - } catch (error) { - console.error("Error during logout:", error); + const credentials = readTransportV2Credentials(apiUrl, "platform"); + const expected = snapshotTransportV2Auth(apiUrl, "platform"); + authViewGeneration.current += 1; + setAuth({ loading: false, developer: undefined }); + try { + if (credentials) { + await platformApi.platformLogout(credentials.refreshToken); } + } catch (error) { + console.error("Error during logout:", error); + } finally { + transportV2Client.clear(apiUrl, "platform", false, expected); + clearAttestationSessions(); } - localStorage.removeItem("access_token"); - localStorage.removeItem("refresh_token"); - clearAttestationSessions(); - setAuth({ - loading: false, - developer: undefined - }); }, verifyEmail: platformApi.verifyPlatformEmail, requestNewVerificationCode: platformApi.requestNewPlatformVerificationCode, @@ -669,8 +706,21 @@ export function OpenSecretDeveloper({ confirmPasswordReset: platformApi.confirmPlatformPasswordReset, changePassword: platformApi.changePlatformPassword, pcrConfig, - getAttestation: (forceRefresh, explicitApiUrl, explicitPcrConfig) => - getAttestation(forceRefresh, explicitApiUrl || apiUrl, explicitPcrConfig || pcrConfig), + getAttestation: (_forceRefresh, explicitApiUrl, explicitPcrConfig) => + transportV2Client.sessionInfo( + explicitApiUrl || apiUrl, + explicitPcrConfig || pcrConfig, + (() => { + const credentials = readTransportV2Credentials(explicitApiUrl || apiUrl, "platform"); + return credentials + ? { + kind: "platform" as const, + principalId: credentials.principalId, + generation: credentials.generation + } + : ({ kind: "anonymous", purpose: "platform" } as const); + })() + ), authenticate, parseAttestationForView, awsRootCertDer: AWS_ROOT_CERT_DER, diff --git a/sdk/src/lib/encryptedApi.ts b/sdk/src/lib/encryptedApi.ts index 76a088604..066cc12d9 100644 --- a/sdk/src/lib/encryptedApi.ts +++ b/sdk/src/lib/encryptedApi.ts @@ -1,259 +1,258 @@ -import { encryptMessage, decryptMessage } from "./encryption"; -import { getAttestation, type Attestation } from "./getAttestation"; -import { getApiPcrConfig, getApiUrl, refreshToken } from "./api"; -import { getPlatformApiUrl, getPlatformPcrConfig, platformRefreshToken } from "./platformApi"; -import { apiConfig } from "./apiConfig"; -import { serializePcrConfig, snapshotPcrConfig, type PcrConfig } from "./pcr"; -import { classifyRecovery } from "./recovery"; - -interface EncryptedResponse { - encrypted: string; -} +import { getApiPcrConfig, getApiUrl } from "./api"; +import { getPlatformApiUrl, getPlatformPcrConfig } from "./platformApi"; +import { canonicalizeTransportV2ApiUrl, readTransportV2Credentials } from "./transportV2/auth"; +import { + transportV2Client, + type TransportV2Authority, + type TransportV2Client +} from "./transportV2/client"; +import type { LogicalMethod } from "./transportV2/envelope"; interface ApiResponse { status: number; + hasData: boolean; data?: T; error?: string; } -interface RequestAuthentication { - token?: string; - refreshAccessToken?: () => Promise; -} - -interface ActiveAttestation { - sessionKey: Uint8Array; - sessionId: string; -} - /** @internal Exported for deterministic transport tests, not from the package entry point. */ export interface EncryptedApiDependencies { - decryptMessage: typeof decryptMessage; - encryptMessage: typeof encryptMessage; - fetch: typeof globalThis.fetch; - getAttestation: typeof getAttestation; + client: Pick; getApiPcrConfig: typeof getApiPcrConfig; getApiUrl: typeof getApiUrl; getPlatformApiUrl: typeof getPlatformApiUrl; getPlatformPcrConfig: typeof getPlatformPcrConfig; - getAccessToken: () => string | null; - refreshAccessToken: (url: string) => Promise; - resolveEndpoint: typeof apiConfig.resolveEndpoint; } const defaultDependencies: EncryptedApiDependencies = { - decryptMessage, - encryptMessage, - fetch: (...args) => globalThis.fetch(...args), - getAttestation, - // Keep circular api.ts/platformApi.ts imports lazy until after module setup. + client: transportV2Client, getApiPcrConfig: () => getApiPcrConfig(), getApiUrl: () => getApiUrl(), getPlatformApiUrl: () => getPlatformApiUrl(), - getPlatformPcrConfig: () => getPlatformPcrConfig(), - getAccessToken: () => window.localStorage.getItem("access_token"), - refreshAccessToken: async (url) => { - console.log("Refreshing access token"); - const refreshFn = apiConfig.getRefreshFunction(url); - console.log(`Using ${refreshFn}`); - if (refreshFn === "platformRefreshToken") { - await platformRefreshToken(); - } else { - await refreshToken(); - } - }, - resolveEndpoint: (url) => apiConfig.resolveEndpoint(url) + getPlatformPcrConfig: () => getPlatformPcrConfig() }; -const attestationRenewals = new WeakMap< - EncryptedApiDependencies["getAttestation"], - Map> ->(); +function logicalMethod(method: string): LogicalMethod { + const normalized = method.toUpperCase(); + if (!(["GET", "POST", "PUT", "PATCH", "DELETE"] as const).includes(normalized as LogicalMethod)) { + throw new Error(`Transport v2 does not support the ${normalized} method.`); + } + return normalized as LogicalMethod; +} -function requireActiveAttestation(attestation: Attestation): ActiveAttestation { - if (!attestation.sessionKey || !attestation.sessionId) { - throw new Error("Failed to make encrypted API call, no attestation available."); +function isPlatformUrl(url: string): boolean { + return new URL(url).pathname.includes("/platform/"); +} + +function logicalPath(apiUrl: string, requestUrl: string): string { + const base = new URL(canonicalizeTransportV2ApiUrl(apiUrl)); + const request = new URL(requestUrl); + const basePath = base.pathname === "/" ? "" : base.pathname.replace(/\/+$/u, ""); + if ( + request.origin !== base.origin || + (basePath && request.pathname !== basePath && !request.pathname.startsWith(`${basePath}/`)) + ) { + throw new Error("Transport v2 request escaped its attested API origin."); } + return basePath ? request.pathname.slice(basePath.length) || "/" : request.pathname; +} + +function isAnonymousPlatformPath(path: string): boolean { + return ( + path === "/platform/login" || + path === "/platform/register" || + path === "/platform/password-reset/request" || + path === "/platform/password-reset/confirm" + ); +} + +function isAnonymousUserPath(path: string): boolean { + return ( + path === "/login" || + path === "/register" || + path.startsWith("/password-reset/") || + path === "/auth/github" || + path === "/auth/github/callback" || + path === "/auth/google" || + path === "/auth/google/callback" || + path === "/auth/apple" || + path === "/auth/apple/callback" || + path === "/auth/apple/native" + ); +} + +function storedAuthority( + apiUrl: string, + kind: "user" | "platform" +): Extract { + const credentials = readTransportV2Credentials(apiUrl, kind); + if (!credentials) throw new Error("A fresh transport v2 sign-in is required."); return { - sessionKey: attestation.sessionKey, - sessionId: attestation.sessionId + kind, + principalId: credentials.principalId, + generation: credentials.generation }; } -async function renewAttestation( - failedSessionId: string, - apiUrl: string, - pcrConfig: PcrConfig, - dependencies: EncryptedApiDependencies -): Promise { - let renewals = attestationRenewals.get(dependencies.getAttestation); - if (!renewals) { - renewals = new Map(); - attestationRenewals.set(dependencies.getAttestation, renewals); +function encryptedAuthority(url: string, apiUrl: string): TransportV2Authority { + const path = logicalPath(apiUrl, url); + if (isPlatformUrl(url)) { + if (isAnonymousPlatformPath(path)) return { kind: "anonymous", purpose: "platform" }; + if ( + path.startsWith("/platform/verify-email/") && + !readTransportV2Credentials(apiUrl, "platform") + ) { + return { kind: "anonymous", purpose: "platform" }; + } + return storedAuthority(apiUrl, "platform"); + } + if (path === "/v1/models") return { kind: "anonymous", purpose: "public" }; + if (isAnonymousUserPath(path)) return { kind: "anonymous", purpose: "user" }; + if (path.startsWith("/verify-email/") && !readTransportV2Credentials(apiUrl, "user")) { + return { kind: "anonymous", purpose: "user" }; } + return storedAuthority(apiUrl, "user"); +} - const scope = `${apiUrl}\n${serializePcrConfig(pcrConfig)}\n${failedSessionId}`; - let renewal = renewals.get(scope); - if (!renewal) { - let resolveRenewal!: (attestation: ActiveAttestation) => void; - let rejectRenewal!: (reason?: unknown) => void; - renewal = new Promise((resolve, reject) => { - resolveRenewal = resolve; - rejectRenewal = reject; - }); - renewals.set(scope, renewal); +function streamEventError(value: unknown, fallback: string): Error { + if (typeof value === "object" && value !== null) { + const record = value as Record; + if (typeof record.message === "string") return new Error(record.message); + if (typeof record.error === "object" && record.error !== null) { + const error = record.error as Record; + if (typeof error.message === "string") return new Error(error.message); + } + } + return new Error(fallback); +} - const registeredRenewal = renewal; - const registeredRenewals = renewals; - void (async () => { - try { - // Keep the cache comparison inside the already-registered leader. A - // staggered stale response must join here even while the leader's - // forced refresh has temporarily removed the cached session. - const currentAttestation = requireActiveAttestation( - await dependencies.getAttestation(false, apiUrl, pcrConfig) - ); - const renewedAttestation = - currentAttestation.sessionId === failedSessionId - ? requireActiveAttestation(await dependencies.getAttestation(true, apiUrl, pcrConfig)) - : currentAttestation; - resolveRenewal(renewedAttestation); - } catch (error) { - rejectRenewal(error); - } finally { - if (registeredRenewals.get(scope) === registeredRenewal) { - registeredRenewals.delete(scope); - } - if ( - registeredRenewals.size === 0 && - attestationRenewals.get(dependencies.getAttestation) === registeredRenewals - ) { - attestationRenewals.delete(dependencies.getAttestation); - } +function completedResponseFromSse(text: string): unknown { + for (const eventBlock of text.split(/\r?\n\r?\n/u)) { + if (!eventBlock.trim()) continue; + let eventName = ""; + const data: string[] = []; + for (const line of eventBlock.split(/\r?\n/u)) { + if (line.startsWith("event:")) eventName = line.slice(6).trim(); + else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /u, "")); + } + if (data.length === 0 || data.join("\n") === "[DONE]") continue; + let value: unknown; + try { + value = JSON.parse(data.join("\n")); + } catch { + throw new Error("Transport v2 Responses stream contained invalid JSON."); + } + const type = + typeof value === "object" && + value !== null && + typeof (value as Record).type === "string" + ? ((value as Record).type as string) + : eventName; + if (type === "response.completed") { + const response = (value as Record).response; + if (typeof response !== "object" || response === null) { + throw new Error("Transport v2 Responses completion contained no response."); } - })(); + return response; + } + if (type === "response.error" || type === "response.failed" || type === "error") { + throw streamEventError(value, "The response failed."); + } + if (type === "response.cancelled") { + throw streamEventError(value, "The response was cancelled."); + } } - - return renewal; + throw new Error("Transport v2 Responses stream ended without a completed response."); } -async function discardResponse(response: Response): Promise { - try { - await response.body?.cancel(); - } catch { - // A bounded recovery no longer needs this error response. It may already - // be closed in some fetch implementations. +function compatibilityResponseShape(url: string, value: unknown): unknown { + const path = new URL(url).pathname; + if ( + (path.endsWith("/auth/github") || path.endsWith("/auth/google")) && + typeof value === "object" && + value !== null && + typeof (value as Record).state === "string" + ) { + const { state, ...rest } = value as Record; + return { ...rest, csrf_token: state }; } + return value; } -async function performEncryptedApiCall( +async function performTransportV2Call( url: string, method: string, data: T, - authentication: RequestAuthentication, + authority: TransportV2Authority, errorMessage: string | undefined, dependencies: EncryptedApiDependencies ): Promise> { try { - // Snapshot every logical request value before the first transport send. - // Only the token, session ID, and ciphertext may change on recovery. - const plaintextBody = data ? JSON.stringify(data) : undefined; - const endpoint = dependencies.resolveEndpoint(url); - const explicitApiUrl = - endpoint.context === "platform" ? dependencies.getPlatformApiUrl() : dependencies.getApiUrl(); - const pcrConfig = snapshotPcrConfig( - endpoint.context === "platform" - ? dependencies.getPlatformPcrConfig() - : dependencies.getApiPcrConfig() - ); - - let token = authentication.token; - let attestation = await dependencies.getAttestation(false, explicitApiUrl, pcrConfig); - let replayed = false; + const platform = isPlatformUrl(url); + const apiUrl = platform ? dependencies.getPlatformApiUrl() : dependencies.getApiUrl(); + const pcrConfig = platform + ? dependencies.getPlatformPcrConfig() + : dependencies.getApiPcrConfig(); + const body = data === undefined ? null : new TextEncoder().encode(JSON.stringify(data)); + const requestMethod = logicalMethod(method); + const responseMode = + requestMethod === "POST" && logicalPath(apiUrl, url) === "/v1/responses" ? "stream" : "unary"; + let response: Response; + try { + response = await dependencies.client.fetch({ + apiUrl, + pcrConfig, + url, + method: requestMethod, + headers: body === null ? undefined : { "content-type": "application/json" }, + body, + responseMode, + authority + }); + } finally { + body?.fill(0); + } - const requireSession = async (forceRefresh: boolean) => { - if (forceRefresh || !attestation.sessionKey || !attestation.sessionId) { - attestation = await dependencies.getAttestation(true, explicitApiUrl, pcrConfig); - } - if (!attestation.sessionKey || !attestation.sessionId) { - throw new Error("Failed to make encrypted API call, no attestation available."); + const text = await response.text(); + if (!response.ok) { + let message: string | undefined; + try { + const value = JSON.parse(text) as { message?: unknown; error?: unknown }; + if (typeof value.message === "string") message = value.message; + else if (typeof value.error === "string") message = value.error; + } catch { + if (text.trim()) message = text; } return { - sessionKey: attestation.sessionKey, - sessionId: attestation.sessionId + status: response.status, + hasData: false, + error: message || errorMessage || `HTTP error! Status: ${response.status}` }; - }; + } - while (true) { - const session = await requireSession(false); - const encryptedData = plaintextBody - ? dependencies.encryptMessage(session.sessionKey, plaintextBody) - : undefined; - const headers: Record = { - "Content-Type": "application/json", - "x-session-id": session.sessionId + if (responseMode === "stream") { + return { + status: response.status, + hasData: true, + data: completedResponseFromSse(text) as U }; - if (token) headers.Authorization = `Bearer ${token}`; - - const response = await dependencies.fetch(url, { - method, - headers, - body: encryptedData ? JSON.stringify({ encrypted: encryptedData }) : undefined - }); - const recovery = classifyRecovery(response.status, response.headers); - - if (!replayed && recovery === "renew_session") { - replayed = true; - await discardResponse(response); - console.log("Session not found, renewing attestation and retrying once"); - attestation = await renewAttestation( - session.sessionId, - explicitApiUrl, - pcrConfig, - dependencies - ); - continue; - } - - if (!replayed && recovery === "refresh_access_token" && authentication.refreshAccessToken) { - replayed = true; - await discardResponse(response); - token = await authentication.refreshAccessToken(); - // The encrypted refresh request can repair a stale session with its - // own replay budget, so always reload the current session afterward. - attestation = await dependencies.getAttestation(false, explicitApiUrl, pcrConfig); - continue; - } - - const result: ApiResponse = { status: response.status }; - if (!response.ok) { - try { - const errorBody = (await response.json()) as { message?: string }; - result.error = - errorBody.message || errorMessage || `HTTP error! Status: ${response.status}`; - } catch { - result.error = errorMessage || `HTTP error! Status: ${response.status}`; - } - return result; - } - - try { - const encryptedResponse = (await response.json()) as EncryptedResponse; - const decryptedResponse = dependencies.decryptMessage( - session.sessionKey, - encryptedResponse.encrypted - ); - result.data = JSON.parse(decryptedResponse) as U; - } catch (error) { - console.error("Error decrypting or parsing response:", error); - result.status = 500; - result.error = "Failed to decrypt or parse the response"; - } - return result; + } + if (text.length === 0) { + return { status: response.status, hasData: true, data: undefined as U }; + } + try { + return { + status: response.status, + hasData: true, + data: compatibilityResponseShape(url, JSON.parse(text)) as U + }; + } catch { + return { status: 500, hasData: false, error: "Failed to parse the authenticated response" }; } } catch (error) { return { status: 500, + hasData: false, error: error instanceof Error ? error.message : "Unknown error occurred" }; } @@ -261,8 +260,8 @@ async function performEncryptedApiCall( function unwrapApiResponse(response: ApiResponse, missingDataMessage: string): U { if (response.error) throw new Error(response.error); - if (!response.data) throw new Error(missingDataMessage); - return response.data; + if (!response.hasData) throw new Error(missingDataMessage); + return response.data as U; } export async function authenticatedApiCall( @@ -282,34 +281,22 @@ export async function authenticatedApiCallWithDependencies( errorMessage: string | undefined, dependencies: EncryptedApiDependencies ): Promise { - try { - const accessToken = dependencies.getAccessToken(); - if (!accessToken) throw new Error("No access token available"); - - const response = await performEncryptedApiCall( - url, - method, - data, - { - token: accessToken, - refreshAccessToken: async () => { - await dependencies.refreshAccessToken(url); - const refreshedToken = dependencies.getAccessToken(); - if (!refreshedToken) throw new Error("No access token available"); - return refreshedToken; - } - }, - errorMessage, - dependencies - ); - return unwrapApiResponse(response, "No data received from the server"); - } catch (error) { - console.error(error); - throw error; - } + const platform = isPlatformUrl(url); + const authority = storedAuthority( + platform ? dependencies.getPlatformApiUrl() : dependencies.getApiUrl(), + platform ? "platform" : "user" + ); + const response = await performTransportV2Call( + url, + method, + data, + authority, + errorMessage, + dependencies + ); + return unwrapApiResponse(response, "No data received from the server"); } -// Special version for OpenAI endpoints that supports API keys export async function openAiAuthenticatedApiCall( url: string, method: string, @@ -336,15 +323,15 @@ export async function openAiAuthenticatedApiCallWithDependencies( apiKey: string | undefined, dependencies: EncryptedApiDependencies ): Promise { - if (!apiKey) { - return authenticatedApiCallWithDependencies(url, method, data, errorMessage, dependencies); - } - - const response = await performEncryptedApiCall( + const authority: TransportV2Authority = + apiKey !== undefined + ? { kind: "api_key", value: apiKey } + : storedAuthority(dependencies.getApiUrl(), "user"); + const response = await performTransportV2Call( url, method, data, - { token: apiKey }, + authority, errorMessage, dependencies ); @@ -373,15 +360,16 @@ export async function encryptedApiCallWithDependencies( url: string, method: string, data: T, - accessToken: string | undefined, + _accessToken: string | undefined, errorMessage: string | undefined, dependencies: EncryptedApiDependencies ): Promise { - const response = await performEncryptedApiCall( + const apiUrl = isPlatformUrl(url) ? dependencies.getPlatformApiUrl() : dependencies.getApiUrl(); + const response = await performTransportV2Call( url, method, data, - { token: accessToken }, + encryptedAuthority(url, apiUrl), errorMessage, dependencies ); diff --git a/sdk/src/lib/index.ts b/sdk/src/lib/index.ts index bd18e8879..8a8c1a7ea 100644 --- a/sdk/src/lib/index.ts +++ b/sdk/src/lib/index.ts @@ -3,6 +3,7 @@ export type { KVListItem, LoginResponse, UserResponse, + NativeHandoffGrantResponse, GithubAuthResponse, GoogleAuthResponse, DocumentResponse, @@ -75,6 +76,9 @@ export type { // Export API key management functions export { createApiKey, listApiKeys, deleteApiKey } from "./api"; +// Hosted-browser half of the native OAuth handoff. Redemption remains native. +export { mintNativeHandoffGrant } from "./api"; + export { fetchModels, fetchModelCatalog } from "./api"; // Export conversation and conversation-project API functions @@ -116,6 +120,14 @@ export { // Export AI customization options export { createCustomFetch, type CustomFetchOptions } from "./ai"; +// Sensitive portable user-auth state for trusted local native bridges only. +// The bundle is encoded, not encrypted; never place it in a URL or log. +export { + exportTransportV2AuthBundle, + importTransportV2AuthBundle, + type TransportV2SessionInfo +} from "./transportV2/publicAuth"; + // Re-export Model type from OpenAI for convenience export type { Model } from "openai/resources/models.js"; diff --git a/sdk/src/lib/main.tsx b/sdk/src/lib/main.tsx index e5a23ca88..6b2a1b996 100644 --- a/sdk/src/lib/main.tsx +++ b/sdk/src/lib/main.tsx @@ -1,7 +1,7 @@ -import React, { createContext, useState, useEffect } from "react"; +import React, { createContext, useState, useEffect, useRef } from "react"; import * as api from "./api"; import { createCustomFetch } from "./ai"; -import { clearAttestationSessions, getAttestation } from "./getAttestation"; +import { clearAttestationSessions } from "./getAttestation"; import type { Model } from "openai/resources/models.js"; import { authenticate } from "./attestation"; import { @@ -13,6 +13,13 @@ import { import type { AttestationDocument } from "./attestation"; import type { LoginResponse, ThirdPartyTokenResponse, DocumentResponse } from "./api"; import { PcrConfig } from "./pcr"; +import { + clearLegacyTransportV1Credentials, + readTransportV2Credentials, + snapshotTransportV2Auth, + subscribeTransportV2AuthInvalidation +} from "./transportV2/auth"; +import { transportV2Client, type TransportV2SessionInfo } from "./transportV2/client"; const DEFAULT_PCR_CONFIG: PcrConfig = { environment: "production" }; @@ -46,7 +53,7 @@ export type OpenSecretContextType = { * Authenticates a user with email and password. * * - Calls the login API endpoint with the configured clientId - * - Stores access_token and refresh_token in localStorage + * - Installs origin-scoped transport v2 resumption descriptors * - Updates the auth state with user information * - Throws an error if authentication fails * @@ -68,7 +75,7 @@ export type OpenSecretContextType = { * * * - Calls the registration API endpoint - * - Stores access_token and refresh_token in localStorage + * - Installs origin-scoped transport v2 resumption descriptors * - Updates the auth state with new user information * - Throws an error if account creation fails */ @@ -83,7 +90,7 @@ export type OpenSecretContextType = { * * * - Calls the login API endpoint - * - Stores access_token and refresh_token in localStorage + * - Installs origin-scoped transport v2 resumption descriptors * - Updates the auth state with user information * - Throws an error if authentication fails */ @@ -98,7 +105,7 @@ export type OpenSecretContextType = { * * * - Calls the registration API endpoint - * - Stores access_token and refresh_token in localStorage + * - Installs origin-scoped transport v2 resumption descriptors * - Updates the auth state with new user information * - Throws an error if account creation fails */ @@ -110,9 +117,8 @@ export type OpenSecretContextType = { * @throws {Error} If logout fails * * - * - Calls the logout API endpoint with the current refresh_token - * - Removes access_token, refresh_token from localStorage - * - Removes session-related items from sessionStorage + * - Presents the resumption credential inside the encrypted logout request + * - Clears origin-scoped transport v2 credentials and OAuth continuation state * - Resets the auth state to show no user is authenticated */ signOut: () => Promise; @@ -230,6 +236,12 @@ export type OpenSecretContextType = { handleAppleCallback: (code: string, state: string, inviteCode: string) => Promise; handleAppleNativeSignIn: (appleUser: api.AppleUser, inviteCode?: string) => Promise; + /** + * Mints a short-lived grant bound to a native app's pre-established, + * attested transport-v2 session. The native app redeems it separately. + */ + mintNativeHandoffGrant: typeof api.mintNativeHandoffGrant; + /** * Retrieves the user's private key mnemonic phrase * @param options - Optional key derivation options @@ -339,7 +351,8 @@ export type OpenSecretContextType = { * defaultHeaders: { * "Accept-Encoding": "identity" * }, - * fetch: os.aiCustomFetch + * fetch: os.aiCustomFetch, + * maxRetries: 0 * }); * ``` */ @@ -360,7 +373,11 @@ export type OpenSecretContextType = { /** * Gets an attested session after enforcing the effective PCR0 trust policy */ - getAttestation: typeof getAttestation; + getAttestation: ( + forceRefresh?: boolean, + explicitApiUrl?: string, + explicitPcrConfig?: PcrConfig + ) => Promise; /** * Authenticates an attestation document @@ -936,6 +953,7 @@ export const OpenSecretContext = createContext({ initiateAppleAuth: async () => ({ auth_url: "", state: "" }), handleAppleCallback: async () => {}, handleAppleNativeSignIn: async () => {}, + mintNativeHandoffGrant: api.mintNativeHandoffGrant, getPrivateKey: api.fetchPrivateKey, getPrivateKeyBytes: api.fetchPrivateKeyBytes, getPublicKey: api.fetchPublicKey, @@ -943,7 +961,9 @@ export const OpenSecretContext = createContext({ aiCustomFetch: async () => new Response(), apiUrl: "", pcrConfig: DEFAULT_PCR_CONFIG, - getAttestation, + getAttestation: async () => { + throw new Error("getAttestation called outside of OpenSecretProvider"); + }, authenticate, parseAttestationForView, awsRootCertDer: AWS_ROOT_CERT_DER, @@ -1036,11 +1056,20 @@ export function OpenSecretProvider({ }); const [apiKey, setApiKeyState] = useState(); const [aiCustomFetch, setAiCustomFetch] = useState(); + const authViewGeneration = useRef(0); // Validates UUID-with-dashes (v1–v5) and trims input; set undefined to clear const setApiKey = (key: string | undefined) => { + const install = (next: string | undefined) => { + setApiKeyState((previous) => { + if (previous && previous !== next) { + void transportV2Client.retireApiKey(apiUrl, pcrConfig, previous); + } + return next; + }); + }; if (key === undefined) { - setApiKeyState(undefined); + install(undefined); return; } const trimmed = key.trim(); @@ -1048,10 +1077,10 @@ export function OpenSecretProvider({ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; if (!uuidWithDashes.test(trimmed)) { console.warn("setApiKey: provided key does not look like a UUID; clearing apiKey"); - setApiKeyState(undefined); + install(undefined); return; } - setApiKeyState(trimmed); + install(trimmed); }; useEffect(() => { @@ -1066,6 +1095,8 @@ export function OpenSecretProvider({ ); } api.setApiUrl(apiUrl, pcrConfig); + // V4 never resumes or transmits the legacy single-key transport session. + clearAttestationSessions(); // Configure the apiConfig service with the app URL // Using dynamic import to avoid circular dependencies @@ -1086,9 +1117,10 @@ export function OpenSecretProvider({ }, [apiUrl, apiKey, pcrConfig]); async function fetchUser() { - const access_token = window.localStorage.getItem("access_token"); - const refresh_token = window.localStorage.getItem("refresh_token"); - if (!access_token || !refresh_token) { + const viewGeneration = ++authViewGeneration.current; + const expected = snapshotTransportV2Auth(apiUrl, "user"); + if (expected.principalId === null) { + clearLegacyTransportV1Credentials(); setAuth({ loading: false, user: undefined @@ -1098,12 +1130,29 @@ export function OpenSecretProvider({ try { const user = await api.fetchUser(); + const current = snapshotTransportV2Auth(apiUrl, "user"); + if ( + authViewGeneration.current !== viewGeneration || + current.principalId !== expected.principalId || + user.user.id !== current.principalId + ) { + return; + } setAuth({ loading: false, user }); } catch (error) { console.error("Failed to fetch user:", error); + const current = snapshotTransportV2Auth(apiUrl, "user"); + if ( + authViewGeneration.current !== viewGeneration || + (current.principalId !== null && + (current.principalId !== expected.principalId || + current.generation !== expected.generation)) + ) { + return; + } setAuth({ loading: false, user: undefined @@ -1113,14 +1162,21 @@ export function OpenSecretProvider({ useEffect(() => { fetchUser(); - }, []); + }, [apiUrl, pcrConfig]); + + useEffect( + () => + subscribeTransportV2AuthInvalidation(apiUrl, "user", () => { + authViewGeneration.current += 1; + setAuth({ loading: false, user: undefined }); + }), + [apiUrl] + ); async function signIn(email: string, password: string) { console.log("Signing in"); try { - const { access_token, refresh_token } = await api.fetchLogin(email, password, clientId); - window.localStorage.setItem("access_token", access_token); - window.localStorage.setItem("refresh_token", refresh_token); + await api.fetchLogin(email, password, clientId); // Clear API key on new sign-in to ensure user-scoped keys setApiKey(undefined); await fetchUser(); @@ -1132,15 +1188,7 @@ export function OpenSecretProvider({ async function signUp(email: string, password: string, inviteCode: string, name?: string) { try { - const { access_token, refresh_token } = await api.fetchSignUp( - email, - password, - inviteCode, - clientId, - name || null - ); - window.localStorage.setItem("access_token", access_token); - window.localStorage.setItem("refresh_token", refresh_token); + await api.fetchSignUp(email, password, inviteCode, clientId, name || null); // Clear API key on new sign-up to ensure user-scoped keys setApiKey(undefined); await fetchUser(); @@ -1153,9 +1201,7 @@ export function OpenSecretProvider({ async function signInGuest(id: string, password: string) { console.log("Signing in Guest"); try { - const { access_token, refresh_token } = await api.fetchGuestLogin(id, password, clientId); - window.localStorage.setItem("access_token", access_token); - window.localStorage.setItem("refresh_token", refresh_token); + await api.fetchGuestLogin(id, password, clientId); // Clear API key on guest sign-in to ensure user-scoped keys setApiKey(undefined); await fetchUser(); @@ -1167,17 +1213,11 @@ export function OpenSecretProvider({ async function signUpGuest(password: string, inviteCode: string) { try { - const { access_token, refresh_token, id } = await api.fetchGuestSignUp( - password, - inviteCode, - clientId - ); - window.localStorage.setItem("access_token", access_token); - window.localStorage.setItem("refresh_token", refresh_token); + const result = await api.fetchGuestSignUp(password, inviteCode, clientId); // Clear API key on guest sign-up to ensure user-scoped keys setApiKey(undefined); await fetchUser(); - return { access_token, refresh_token, id }; + return result; } catch (error) { console.error(error); throw error; @@ -1185,23 +1225,25 @@ export function OpenSecretProvider({ } async function signOut() { - const refresh_token = window.localStorage.getItem("refresh_token"); - if (refresh_token) { - try { - await api.fetchLogout(refresh_token); - } catch (error) { - console.error("Error during logout:", error); + const credentials = readTransportV2Credentials(apiUrl, "user"); + const expected = snapshotTransportV2Auth(apiUrl, "user"); + authViewGeneration.current += 1; + setAuth({ loading: false, user: undefined }); + // Clear any in-memory API key immediately so no post-logout calls can use it. + setApiKey(undefined); + try { + if (credentials) { + await api.fetchLogout(credentials.refreshToken); } + } catch (error) { + console.error("Error during logout:", error); + } finally { + // Logout is local-first and terminal even if account deletion already + // closed the enclave session. Exact-generation CAS preserves a newer + // login/import that completed while the best-effort send was pending. + transportV2Client.clear(apiUrl, "user", false, expected); + clearAttestationSessions(); } - localStorage.removeItem("access_token"); - localStorage.removeItem("refresh_token"); - clearAttestationSessions(); - // Clear any in-memory API key so no post-logout calls can use it - setApiKey(undefined); - setAuth({ - loading: false, - user: undefined - }); } const initiateGitHubAuth = async (inviteCode: string) => { @@ -1215,13 +1257,7 @@ export function OpenSecretProvider({ const handleGitHubCallback = async (code: string, state: string, inviteCode: string) => { try { - const { access_token, refresh_token } = await api.handleGitHubCallback( - code, - state, - inviteCode - ); - window.localStorage.setItem("access_token", access_token); - window.localStorage.setItem("refresh_token", refresh_token); + await api.handleGitHubCallback(code, state, inviteCode); // Clear API key on OAuth sign-in to ensure user-scoped keys setApiKey(undefined); await fetchUser(); @@ -1242,13 +1278,7 @@ export function OpenSecretProvider({ const handleGoogleCallback = async (code: string, state: string, inviteCode: string) => { try { - const { access_token, refresh_token } = await api.handleGoogleCallback( - code, - state, - inviteCode - ); - window.localStorage.setItem("access_token", access_token); - window.localStorage.setItem("refresh_token", refresh_token); + await api.handleGoogleCallback(code, state, inviteCode); // Clear API key on OAuth sign-in to ensure user-scoped keys setApiKey(undefined); await fetchUser(); @@ -1269,13 +1299,7 @@ export function OpenSecretProvider({ const handleAppleCallback = async (code: string, state: string, inviteCode: string) => { try { - const { access_token, refresh_token } = await api.handleAppleCallback( - code, - state, - inviteCode - ); - window.localStorage.setItem("access_token", access_token); - window.localStorage.setItem("refresh_token", refresh_token); + await api.handleAppleCallback(code, state, inviteCode); // Clear API key on OAuth sign-in to ensure user-scoped keys setApiKey(undefined); await fetchUser(); @@ -1287,13 +1311,7 @@ export function OpenSecretProvider({ const handleAppleNativeSignIn = async (appleUser: api.AppleUser, inviteCode?: string) => { try { - const { access_token, refresh_token } = await api.handleAppleNativeSignIn( - appleUser, - clientId, - inviteCode - ); - window.localStorage.setItem("access_token", access_token); - window.localStorage.setItem("refresh_token", refresh_token); + await api.handleAppleNativeSignIn(appleUser, clientId, inviteCode); // Clear API key on OAuth sign-in to ensure user-scoped keys setApiKey(undefined); await fetchUser(); @@ -1305,7 +1323,7 @@ export function OpenSecretProvider({ const getAttestationDocument = async () => { const nonce = window.crypto.randomUUID(); - const response = await fetch(`${apiUrl}/attestation/${nonce}`); + const response = await fetch(`${apiUrl}/v2/attestation/${nonce}`); if (!response.ok) { throw new Error("Failed to fetch attestation document"); } @@ -1357,6 +1375,7 @@ export function OpenSecretProvider({ initiateAppleAuth, handleAppleCallback, handleAppleNativeSignIn, + mintNativeHandoffGrant: api.mintNativeHandoffGrant, getPrivateKey: api.fetchPrivateKey, getPrivateKeyBytes: api.fetchPrivateKeyBytes, getPublicKey: api.fetchPublicKey, @@ -1364,8 +1383,21 @@ export function OpenSecretProvider({ aiCustomFetch: aiCustomFetch || (async () => new Response()), apiUrl, pcrConfig, - getAttestation: (forceRefresh, explicitApiUrl, explicitPcrConfig) => - getAttestation(forceRefresh, explicitApiUrl || apiUrl, explicitPcrConfig || pcrConfig), + getAttestation: (_forceRefresh, explicitApiUrl, explicitPcrConfig) => + transportV2Client.sessionInfo( + explicitApiUrl || apiUrl, + explicitPcrConfig || pcrConfig, + (() => { + const credentials = readTransportV2Credentials(explicitApiUrl || apiUrl, "user"); + return credentials + ? { + kind: "user" as const, + principalId: credentials.principalId, + generation: credentials.generation + } + : ({ kind: "anonymous", purpose: "public" } as const); + })() + ), authenticate, parseAttestationForView, awsRootCertDer: AWS_ROOT_CERT_DER, diff --git a/sdk/src/lib/platformApi.ts b/sdk/src/lib/platformApi.ts index b4d5991bf..c457bdae9 100644 --- a/sdk/src/lib/platformApi.ts +++ b/sdk/src/lib/platformApi.ts @@ -1,5 +1,7 @@ import { encryptedApiCall, authenticatedApiCall } from "./encryptedApi"; import { snapshotPcrConfig, type PcrConfig } from "./pcr"; +import { readTransportV2Credentials } from "./transportV2/auth"; +import { transportV2Client } from "./transportV2/client"; // Platform Auth Types export type PlatformLoginResponse = { @@ -200,9 +202,9 @@ export async function platformLogout(refresh_token: string): Promise { * Refreshes platform access and refresh tokens * * This function: - * 1. Gets the refresh token from localStorage - * 2. Calls the platform-specific refresh endpoint (/platform/refresh) - * 3. Updates localStorage with the new tokens + * 1. Reads the origin-scoped transport v2 resumption descriptor + * 2. Establishes a fresh attested anonymous session + * 3. Presents the descriptor only inside the encrypted platform refresh request * * The platform refresh endpoint expects: * - A refresh token with audience "platform_refresh" in the request body @@ -211,23 +213,26 @@ export async function platformLogout(refresh_token: string): Promise { * It returns new access and refresh tokens if validation succeeds. */ export async function platformRefreshToken(): Promise { - const refresh_token = window.localStorage.getItem("refresh_token"); - if (!refresh_token) throw new Error("No refresh token available"); - - const refreshData = { refresh_token }; + if (!readTransportV2Credentials(platformApiUrl, "platform")) { + throw new Error("A fresh transport v2 platform sign-in is required."); + } try { - const response = await encryptedApiCall( - `${platformApiUrl}/platform/refresh`, - "POST", - refreshData, - undefined, - "Failed to refresh platform token" - ); - - window.localStorage.setItem("access_token", response.access_token); - window.localStorage.setItem("refresh_token", response.refresh_token); - return response; + const response = await transportV2Client.refresh(platformApiUrl, "platform", platformPcrConfig); + const value = (await response.json()) as Partial & { + message?: unknown; + }; + if (!response.ok) { + throw new Error( + typeof value.message === "string" + ? value.message + : `Failed to refresh platform token: ${response.status}` + ); + } + if (typeof value.access_token !== "string" || typeof value.refresh_token !== "string") { + throw new Error("Transport v2 platform refresh returned invalid credentials."); + } + return { access_token: value.access_token, refresh_token: value.refresh_token }; } catch (error) { console.error("Error refreshing platform token:", error); throw error; diff --git a/sdk/src/lib/test/customFetch.test.ts b/sdk/src/lib/test/customFetch.test.ts index 5109364b6..5826ac34a 100644 --- a/sdk/src/lib/test/customFetch.test.ts +++ b/sdk/src/lib/test/customFetch.test.ts @@ -1,1257 +1,292 @@ -import { beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, mock, test } from "bun:test"; +import OpenAI from "openai"; import { createCustomFetchWithDependencies, type CustomFetchDependencies } from "../ai"; -import { getApiPcrConfig, getApiUrl, setApiUrl } from "../api"; -import type { Attestation } from "../getAttestation"; -import type { PcrConfig } from "../pcr"; -import { ERROR_CODE_HEADER, ERROR_CONTRACT_HEADER } from "../recovery"; - -const staleKey = new Uint8Array(32).fill(1); -const freshKey = new Uint8Array(32).fill(2); -const staleAttestation: Attestation = { sessionKey: staleKey, sessionId: "stale-session" }; -const freshAttestation: Attestation = { sessionKey: freshKey, sessionId: "fresh-session" }; - -interface RecordedRequest { - authorization: string | null; - encryptedBody: string | undefined; - sessionId: string | null; -} - -function recordRequest(init?: RequestInit): RecordedRequest { - const headers = new Headers(init?.headers); - const body = init?.body ? (JSON.parse(init.body as string) as { encrypted?: string }) : undefined; - - return { - authorization: headers.get("Authorization"), - encryptedBody: body?.encrypted, - sessionId: headers.get("x-session-id") +import { clearTransportV2Credentials, installTransportV2Credentials } from "../transportV2/auth"; +import type { TransportV2FetchInput } from "../transportV2/client"; + +const apiUrl = "https://api.example.test/base"; +const userId = "00112233-4455-6677-8899-aabbccddeeff"; + +function token(kind: "access_descriptor" | "resumption"): string { + const audience = + kind === "access_descriptor" + ? "urn:opensecret:internal:transport-v2:user:access-descriptor" + : "urn:opensecret:internal:transport-v2:user:resumption"; + const claims = { + iss: "urn:opensecret:transport-v2", + aud: audience, + tv: 2, + tk: kind, + pk: "user", + sub: userId, + exp: Math.floor(Date.now() / 1000) + 3600 }; + return `e30.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.c2ln`; } -function encryptForTest(sessionKey: Uint8Array, plaintext: string): string { - return `${sessionKey[0]}:${plaintext}`; -} - -function decryptForTest(sessionKey: Uint8Array, ciphertext: string): string { - const expectedPrefix = `${sessionKey[0]}:`; - if (!ciphertext.startsWith(expectedPrefix)) { - throw new Error(`Ciphertext was not encrypted for key ${sessionKey[0]}`); - } - return ciphertext.slice(expectedPrefix.length); -} - -function contractError(status: number, body: string, code?: string): Response { - const headers = new Headers({ [ERROR_CONTRACT_HEADER]: "1" }); - if (code) headers.set(ERROR_CODE_HEADER, code); - return new Response(body, { status, headers }); -} - -function dependencies(overrides: Partial): CustomFetchDependencies { +function dependencies( + implementation: (input: TransportV2FetchInput) => Promise +): CustomFetchDependencies { return { - decryptMessage: decryptForTest, - encryptMessage: encryptForTest, - fetch: async () => new Response(null, { status: 500 }), - getAttestation: async () => staleAttestation, - refreshToken: async () => ({ - access_token: "refreshed-access-token", - refresh_token: "refreshed-refresh-token" - }), - ...overrides + client: { fetch: mock(implementation) }, + getApiUrl: () => apiUrl, + getApiPcrConfig: () => ({ environment: "development" }) }; } -async function withRequestBodyUnavailable(callback: () => Promise): Promise { - const OriginalRequest = globalThis.Request; - const plaintextBodies = new WeakMap(); - class RequestWithoutBody extends OriginalRequest { - constructor(input: RequestInfo | URL, init?: RequestInit) { - const plaintextBody = - typeof init?.body === "string" - ? init.body - : input instanceof RequestWithoutBody - ? plaintextBodies.get(input) - : undefined; - super( - input, - plaintextBody !== undefined && init?.body == null ? { ...init, body: plaintextBody } : init - ); - if (plaintextBody !== undefined) plaintextBodies.set(this, plaintextBody); - } - - get body() { - return undefined; - } - - async text(): Promise { - const storedPlaintextBody = plaintextBodies.get(this); - const plaintextBody = await super.text(); - return storedPlaintextBody ?? plaintextBody; - } - } - - globalThis.Request = RequestWithoutBody as unknown as typeof Request; - try { - return await callback(); - } finally { - globalThis.Request = OriginalRequest; - } -} - -describe("createCustomFetch stale-session recovery", () => { - beforeEach(() => { - window.localStorage.clear(); - window.sessionStorage.clear(); - }); - - test("renews once and rebuilds an API-key request with the fresh session", async () => { - let currentAttestation = staleAttestation; - let forcedAttestations = 0; - let tokenRefreshes = 0; - const requests: RecordedRequest[] = []; +afterEach(() => { + clearTransportV2Credentials(apiUrl); + localStorage.clear(); + sessionStorage.clear(); +}); +describe("Transport V2 custom Fetch adapter", () => { + test("captures exact binary body/query and strips credential, framing, and provider headers", async () => { + const plaintext = new Uint8Array([0, 1, 2, 0xff]); + const deps = dependencies(async (input) => { + expect(input.url).toBe(`${apiUrl}/v1/audio/transcriptions?b=2&a=1`); + expect(input.method).toBe("POST"); + expect(input.body).toEqual(plaintext); + expect(input.authority).toEqual({ kind: "api_key", value: "real-api-key" }); + const headers = new Headers(input.headers); + expect(headers.get("x-safe-metadata")).toBe("kept"); + expect(headers.get("content-type")).toBe("application/octet-stream"); + for (const forbidden of [ + "authorization", + "user-agent", + "accept", + "accept-encoding", + "content-length", + "content-md5", + "digest", + "x-stainless-lang", + "x-stainless-retry-count", + "x-session-id", + "x-openai-api-key", + "openai-project" + ]) { + expect(headers.has(forbidden)).toBe(false); + } + return new Response("ok", { headers: { "x-authenticated": "yes" } }); + }); const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key" }, - dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) { - forcedAttestations += 1; - currentAttestation = freshAttestation; - } - return currentAttestation; - }, - refreshToken: async () => { - tokenRefreshes += 1; - return { - access_token: "unused-access-token", - refresh_token: "unused-refresh-token" - }; - }, - fetch: async (_input, init) => { - const request = recordRequest(init); - requests.push(request); - - if (request.sessionId === staleAttestation.sessionId) { - return contractError( - 400, - '{"status":400,"message":"Bad Request"}', - "session_not_found" - ); - } - - return Response.json({ encrypted: '2:{"ok":true}' }); - } - }) + { apiKey: "real-api-key", apiUrl, pcrConfig: { environment: "development" } }, + deps ); - const response = await customFetch("https://example.test/v1/responses", { + const response = await customFetch(`${apiUrl}/v1/audio/transcriptions?b=2&a=1`, { method: "POST", - body: '{"prompt":"hello"}' - }); - - expect(await response.json()).toEqual({ ok: true }); - expect(forcedAttestations).toBe(1); - expect(tokenRefreshes).toBe(0); - expect(requests).toEqual([ - { - authorization: "Bearer test-api-key", - encryptedBody: '1:{"prompt":"hello"}', - sessionId: "stale-session" - }, - { - authorization: "Bearer test-api-key", - encryptedBody: '2:{"prompt":"hello"}', - sessionId: "fresh-session" + body: plaintext, + headers: { + authorization: "Bearer fake-openai-key", + "user-agent": "OpenAI/JS test", + accept: "application/json", + "accept-encoding": "identity", + "content-type": "application/octet-stream", + "content-length": "4", + "content-md5": "attacker-controlled", + digest: "sha-256=attacker-controlled", + "x-stainless-lang": "js", + "x-stainless-retry-count": "0", + "x-session-id": "attacker-value", + "x-openai-api-key": "attacker-value", + "openai-project": "attacker-value", + "x-safe-metadata": "kept" } - ]); - }); - - for (const method of ["GET", "HEAD", "POST"] as const) { - test(`keeps Firefox ${method} requests bodyless across stale-session recovery`, async () => { - await withRequestBodyUnavailable(async () => { - let currentAttestation = staleAttestation; - let forcedAttestations = 0; - let encryptions = 0; - const requests: Array<{ - body: BodyInit | null | undefined; - method: string | undefined; - safeHeader: string | null; - sessionId: string | null; - url: string; - }> = []; - - const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key" }, - dependencies({ - encryptMessage: (sessionKey, plaintext) => { - encryptions += 1; - return encryptForTest(sessionKey, plaintext); - }, - getAttestation: async (forceRefresh) => { - if (forceRefresh) { - forcedAttestations += 1; - currentAttestation = freshAttestation; - } - return currentAttestation; - }, - fetch: async (input, init) => { - const headers = new Headers(init?.headers); - requests.push({ - body: init?.body, - method: init?.method, - safeHeader: headers.get("x-safe-provider-header"), - sessionId: headers.get("x-session-id"), - url: String(input) - }); - - return requests.length === 1 - ? contractError(400, "stale", "session_not_found") - : Response.json({ encrypted: '2:{"ok":true}' }); - } - }) - ); - const sourceRequest = new Request( - "https://example.test/v1/conversations/conversation-1?limit=20", - { - method, - headers: { "x-safe-provider-header": "preserve-me" } - } - ); - - expect(await (await customFetch(sourceRequest)).json()).toEqual({ ok: true }); - expect(forcedAttestations).toBe(1); - expect(encryptions).toBe(0); - expect(requests).toEqual([ - { - body: undefined, - method, - safeHeader: "preserve-me", - sessionId: "stale-session", - url: "https://example.test/v1/conversations/conversation-1?limit=20" - }, - { - body: undefined, - method, - safeHeader: "preserve-me", - sessionId: "fresh-session", - url: "https://example.test/v1/conversations/conversation-1?limit=20" - } - ]); - }); - }); - } - - test("keeps Firefox POST plaintext encryption and replay intact", async () => { - await withRequestBodyUnavailable(async () => { - let currentAttestation = staleAttestation; - let forcedAttestations = 0; - const requests: RecordedRequest[] = []; - - const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key" }, - dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) { - forcedAttestations += 1; - currentAttestation = freshAttestation; - } - return currentAttestation; - }, - fetch: async (_input, init) => { - const request = recordRequest(init); - requests.push(request); - return requests.length === 1 - ? contractError(400, "stale", "session_not_found") - : Response.json({ encrypted: '2:{"ok":true}' }); - } - }) - ); - const sourceRequest = new Request("https://example.test/v1/responses", { - method: "POST", - body: '{"prompt":"preserve this"}' - }); - - expect(await (await customFetch(sourceRequest)).json()).toEqual({ ok: true }); - expect(forcedAttestations).toBe(1); - expect(requests).toEqual([ - { - authorization: "Bearer test-api-key", - encryptedBody: '1:{"prompt":"preserve this"}', - sessionId: "stale-session" - }, - { - authorization: "Bearer test-api-key", - encryptedBody: '2:{"prompt":"preserve this"}', - sessionId: "fresh-session" - } - ]); }); + expect(await response.text()).toBe("ok"); + expect(response.headers.get("x-authenticated")).toBe("yes"); }); - for (const source of ["RequestInit", "Request"] as const) { - test(`preserves an explicitly empty Firefox POST body from ${source}`, async () => { - await withRequestBodyUnavailable(async () => { - let currentAttestation = staleAttestation; - const requests: RecordedRequest[] = []; - - const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key" }, - dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) currentAttestation = freshAttestation; - return currentAttestation; - }, - fetch: async (_input, init) => { - const request = recordRequest(init); - requests.push(request); - return requests.length === 1 - ? contractError(400, "stale", "session_not_found") - : Response.json({ encrypted: '2:{"ok":true}' }); - } - }) - ); - const url = "https://example.test/v1/responses"; - - const response = - source === "RequestInit" - ? await customFetch(url, { method: "POST", body: "" }) - : await customFetch(new Request(url, { method: "POST", body: "" })); - - expect(await response.json()).toEqual({ ok: true }); - expect(requests).toEqual([ - { - authorization: "Bearer test-api-key", - encryptedBody: "1:", - sessionId: "stale-session" - }, - { - authorization: "Bearer test-api-key", - encryptedBody: "2:", - sessionId: "fresh-session" - } - ]); - }); + test("preserves no-body versus explicitly empty body", async () => { + const bodies: Array = []; + const deps = dependencies(async (input) => { + bodies.push(input.body); + return Response.json({ ok: true }); }); - } - - test("stops after one attestation retry when 400 persists", async () => { - let currentAttestation = staleAttestation; - let forcedAttestations = 0; - let requests = 0; - - const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key" }, - dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) { - forcedAttestations += 1; - currentAttestation = freshAttestation; - } - return currentAttestation; - }, - fetch: async () => { - requests += 1; - return contractError(400, "still bad", "session_not_found"); - } - }) - ); + const customFetch = createCustomFetchWithDependencies({ apiKey: "key", apiUrl }, deps); - await expect( - customFetch("https://example.test/v1/responses", { - method: "POST", - body: '{"prompt":"hello"}' - }) - ).rejects.toThrow("Request failed with status 400: still bad"); - - expect(forcedAttestations).toBe(1); - expect(requests).toBe(2); + await customFetch(`${apiUrl}/v1/models`, { method: "GET" }); + await customFetch(`${apiUrl}/v1/audio/transcriptions`, { method: "POST", body: "" }); + expect(bodies[0]).toBeNull(); + expect(bodies[1]).toEqual(new Uint8Array(0)); }); - test("keeps legacy headerless 400 session recovery", async () => { - let currentAttestation = staleAttestation; - let forcedAttestations = 0; - let requests = 0; - const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key" }, - dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) { - forcedAttestations += 1; - currentAttestation = freshAttestation; - } - return currentAttestation; - }, - fetch: async () => { - requests += 1; - return requests === 1 - ? new Response("legacy stale session", { status: 400 }) - : Response.json({ encrypted: '2:{"ok":true}' }); - } - }) - ); - - expect( - await ( - await customFetch("https://example.test/v1/responses", { - method: "POST", - body: '{"prompt":"hello"}' - }) - ).json() - ).toEqual({ ok: true }); - expect(requests).toBe(2); - expect(forcedAttestations).toBe(1); - }); - - test("keeps legacy headerless 401 JWT recovery", async () => { - window.localStorage.setItem("access_token", "expired-access-token"); - let requests = 0; - let tokenRefreshes = 0; - const customFetch = createCustomFetchWithDependencies( - undefined, - dependencies({ - refreshToken: async () => { - tokenRefreshes += 1; - window.localStorage.setItem("access_token", "fresh-access-token"); - return { - access_token: "fresh-access-token", - refresh_token: "fresh-refresh-token" - }; - }, - fetch: async () => { - requests += 1; - return requests === 1 - ? new Response("legacy expired JWT", { status: 401 }) - : Response.json({ encrypted: '1:{"ok":true}' }); - } - }) - ); - - expect(await (await customFetch("https://example.test/v1/responses")).json()).toEqual({ - ok: true + test("selects streaming only for Responses create or strict chat stream true", async () => { + const modes: string[] = []; + const deps = dependencies(async (input) => { + modes.push(input.responseMode); + return new Response("stream-or-json"); }); - expect(requests).toBe(2); - expect(tokenRefreshes).toBe(1); - }); + const customFetch = createCustomFetchWithDependencies({ apiKey: "key", apiUrl }, deps); - for (const ordinaryError of [ - { status: 400, code: undefined, name: "ordinary v1 400" }, - { status: 401, code: "invalid_jwt", name: "ordinary v1 401" } - ]) { - test(`${ordinaryError.name} fails closed without replay`, async () => { - window.localStorage.setItem("access_token", "access-token"); - let requests = 0; - let forcedAttestations = 0; - let tokenRefreshes = 0; - const customFetch = createCustomFetchWithDependencies( - undefined, - dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) forcedAttestations += 1; - return staleAttestation; - }, - refreshToken: async () => { - tokenRefreshes += 1; - return { - access_token: "unused-access-token", - refresh_token: "unused-refresh-token" - }; - }, - fetch: async () => { - requests += 1; - return contractError( - ordinaryError.status, - `ordinary-${ordinaryError.status}`, - ordinaryError.code - ); - } - }) - ); - - await expect(customFetch("https://example.test/v1/responses")).rejects.toThrow( - `Request failed with status ${ordinaryError.status}: ordinary-${ordinaryError.status}` - ); - expect(requests).toBe(1); - expect(forcedAttestations).toBe(0); - expect(tokenRefreshes).toBe(0); + await customFetch(`${apiUrl}/v1/responses`, { method: "POST", body: "{}" }); + await customFetch(`${apiUrl}/v1/chat/completions`, { + method: "POST", + body: JSON.stringify({ stream: true }) }); - } - - test("rebuilds the request after a JWT refresh replaces the attestation", async () => { - window.localStorage.setItem("access_token", "expired-access-token"); - let currentAttestation = staleAttestation; - let tokenRefreshes = 0; - let forcedAttestations = 0; - const requests: RecordedRequest[] = []; - - const customFetch = createCustomFetchWithDependencies( - undefined, - dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) forcedAttestations += 1; - return currentAttestation; - }, - refreshToken: async () => { - tokenRefreshes += 1; - window.localStorage.setItem("access_token", "fresh-access-token"); - currentAttestation = freshAttestation; - return { - access_token: "fresh-access-token", - refresh_token: "fresh-refresh-token" - }; - }, - fetch: async (_input, init) => { - requests.push(recordRequest(init)); - if (requests.length === 1) { - return contractError(401, "expired JWT", "access_token_expired"); - } - return Response.json({ encrypted: '2:{"ok":true}' }); - } - }) - ); - - const response = await customFetch("https://example.test/v1/responses", { + await customFetch(`${apiUrl}/v1/chat/completions`, { method: "POST", - body: '{"prompt":"hello"}' + body: JSON.stringify({ stream: "true" }) }); - - expect(await response.json()).toEqual({ ok: true }); - expect(tokenRefreshes).toBe(1); - expect(forcedAttestations).toBe(0); - expect(requests).toEqual([ - { - authorization: "Bearer expired-access-token", - encryptedBody: '1:{"prompt":"hello"}', - sessionId: "stale-session" - }, - { - authorization: "Bearer fresh-access-token", - encryptedBody: '2:{"prompt":"hello"}', - sessionId: "fresh-session" - } - ]); + expect(modes).toEqual(["stream", "stream", "unary"]); }); - test("uses one target replay budget when recoverable reasons alternate", async () => { - window.localStorage.setItem("access_token", "expired-access-token"); - let requests = 0; - let tokenRefreshes = 0; - let forcedAttestations = 0; - const customFetch = createCustomFetchWithDependencies( - undefined, - dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) forcedAttestations += 1; - return staleAttestation; - }, - refreshToken: async () => { - tokenRefreshes += 1; - window.localStorage.setItem("access_token", "fresh-access-token"); - return { - access_token: "fresh-access-token", - refresh_token: "fresh-refresh-token" - }; - }, - fetch: async () => { - requests += 1; - return requests === 1 - ? contractError(401, "expired", "access_token_expired") - : contractError(400, "stale", "session_not_found"); - } - }) - ); - - await expect(customFetch("https://example.test/v1/responses")).rejects.toThrow( - "Request failed with status 400: stale" - ); - expect(requests).toBe(2); - expect(tokenRefreshes).toBe(1); - expect(forcedAttestations).toBe(0); - }); - - test("never refreshes a JWT for an API-key 401", async () => { - let requests = 0; - let tokenRefreshes = 0; - const customFetch = createCustomFetchWithDependencies( - { apiKey: "invalid-api-key" }, - dependencies({ - refreshToken: async () => { - tokenRefreshes += 1; - return { - access_token: "unused-access-token", - refresh_token: "unused-refresh-token" - }; - }, - fetch: async () => { - requests += 1; - return contractError(401, "expired", "access_token_expired"); - } - }) - ); - - await expect(customFetch("https://example.test/v1/responses")).rejects.toThrow( - "Request failed with status 401: expired" - ); - expect(requests).toBe(1); - expect(tokenRefreshes).toBe(0); + test("uses the installed user authority and never exposes its descriptor as a header", async () => { + installTransportV2Credentials(apiUrl, "user", token("access_descriptor"), token("resumption")); + const deps = dependencies(async (input) => { + expect(input.authority).toMatchObject({ + kind: "user", + principalId: "00112233-4455-6677-8899-aabbccddeeff" + }); + expect(input.authority).toHaveProperty("generation"); + expect(new Headers(input.headers).has("authorization")).toBe(false); + return Response.json({ ok: true }); + }); + const customFetch = createCustomFetchWithDependencies({ apiUrl }, deps); + await customFetch(`${apiUrl}/v1/conversations?limit=20`, { method: "GET" }); }); - test("keeps API-key authentication pinned when options mutate in flight", async () => { - const options = { apiKey: "original-api-key" }; - let requests = 0; - let tokenRefreshes = 0; - const customFetch = createCustomFetchWithDependencies( - options, - dependencies({ - refreshToken: async () => { - tokenRefreshes += 1; - return { - access_token: "unused-access-token", - refresh_token: "unused-refresh-token" - }; - }, - fetch: async (_input, init) => { - requests += 1; - expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer original-api-key"); - options.apiKey = ""; - return contractError(401, "expired", "access_token_expired"); - } - }) - ); + test("allows only models to use an anonymous public authority", async () => { + const seen: TransportV2FetchInput[] = []; + const deps = dependencies(async (input) => { + seen.push(input); + return Response.json({ object: "list", data: [] }); + }); + const customFetch = createCustomFetchWithDependencies({ apiUrl }, deps); - await expect(customFetch("https://example.test/v1/responses")).rejects.toThrow( - "Request failed with status 401: expired" - ); - expect(requests).toBe(1); - expect(tokenRefreshes).toBe(0); + await customFetch(`${apiUrl}/v1/models`, { method: "GET" }); + expect(seen[0].authority).toEqual({ kind: "anonymous", purpose: "public" }); + await expect( + customFetch(`${apiUrl}/v1/responses`, { method: "POST", body: "{}" }) + ).rejects.toThrow("fresh transport v2 sign-in"); + expect(seen).toHaveLength(1); }); - test("keeps JWT authentication pinned when options mutate in flight", async () => { - const options: { apiKey?: string } = {}; - window.localStorage.setItem("access_token", "original-access-token"); - let requests = 0; - let tokenRefreshes = 0; - const customFetch = createCustomFetchWithDependencies( - options, - dependencies({ - refreshToken: async () => { - tokenRefreshes += 1; - window.localStorage.setItem("access_token", "fresh-access-token"); - return { - access_token: "fresh-access-token", - refresh_token: "fresh-refresh-token" - }; - }, - fetch: async (_input, init) => { - requests += 1; - const authorization = new Headers(init?.headers).get("Authorization"); - if (requests === 1) { - expect(authorization).toBe("Bearer original-access-token"); - options.apiKey = "late-api-key"; - return contractError(401, "expired", "access_token_expired"); - } - - expect(authorization).toBe("Bearer fresh-access-token"); - return Response.json({ encrypted: '1:{"ok":true}' }); - } - }) - ); + test("does not retry an ambiguous post-send failure", async () => { + let sends = 0; + const deps = dependencies(async () => { + sends += 1; + throw new Error("connection dropped after send"); + }); + const customFetch = createCustomFetchWithDependencies({ apiKey: "key", apiUrl }, deps); - const response = await customFetch("https://example.test/v1/responses"); - expect(await response.json()).toEqual({ ok: true }); - expect(requests).toBe(2); - expect(tokenRefreshes).toBe(1); + await expect( + customFetch(`${apiUrl}/v1/responses/response-id/cancel`, { method: "POST" }) + ).rejects.toThrow("connection dropped after send"); + expect(sends).toBe(1); }); - test("snapshots and preserves the complete logical request across recovery", async () => { - let currentAttestation = staleAttestation; - const controller = new AbortController(); - const url = new URL("https://example.test/v1/responses?model=private&stream=true"); - const sourceHeaders = new Headers({ - "content-type": "application/json", - "x-safe-provider-header": "preserve-me" + test("blocks an OpenAI automatic retry before a second transport send", async () => { + let transportSends = 0; + const deps = dependencies(async () => { + transportSends += 1; + throw new Error("connection dropped after send"); }); - const sourceInit: RequestInit = { - method: "POST", - headers: sourceHeaders, - body: '{"prompt":"original"}', - cache: "no-store", - credentials: "include", - redirect: "manual", - referrerPolicy: "no-referrer", - signal: controller.signal - }; - const requests: Array<{ - url: string; - method: string | undefined; - safeHeader: string | null; - plaintext: string; - cache: RequestCache | undefined; - credentials: RequestCredentials | undefined; - redirect: RequestRedirect | undefined; - referrerPolicy: ReferrerPolicy | undefined; - sameSignal: boolean; - }> = []; - - const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key" }, - dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) currentAttestation = freshAttestation; - return currentAttestation; - }, - fetch: async (input, init) => { - const recorded = recordRequest(init); - const key = recorded.sessionId === "stale-session" ? staleKey : freshKey; - requests.push({ - url: String(input), - method: init?.method, - safeHeader: new Headers(init?.headers).get("x-safe-provider-header"), - plaintext: decryptForTest(key, recorded.encryptedBody!), - cache: init?.cache, - credentials: init?.credentials, - redirect: init?.redirect, - referrerPolicy: init?.referrerPolicy, - sameSignal: init?.signal === controller.signal - }); - - if (requests.length === 1) { - url.searchParams.set("model", "mutated"); - sourceHeaders.set("x-safe-provider-header", "mutated"); - sourceInit.method = "PUT"; - sourceInit.body = '{"prompt":"mutated"}'; - sourceInit.credentials = "omit"; - return contractError(400, "stale", "session_not_found"); - } - return Response.json({ encrypted: '2:{"ok":true}' }); - } - }) - ); - - const responsePromise = customFetch(url, sourceInit); - // snapshotRequest reads the body asynchronously; mutations during that - // yield must not leak into either transport attempt. - sourceInit.cache = "reload"; - sourceInit.referrerPolicy = "origin"; - - expect(await (await responsePromise).json()).toEqual({ ok: true }); - expect(requests).toEqual([ - { - url: "https://example.test/v1/responses?model=private&stream=true", - method: "POST", - safeHeader: "preserve-me", - plaintext: '{"prompt":"original"}', - cache: "no-store", - credentials: "include", - redirect: "manual", - referrerPolicy: "no-referrer", - sameSignal: true + const customFetch = createCustomFetchWithDependencies({ apiKey: "key", apiUrl }, deps); + let customFetchInvocations = 0; + const openai = new OpenAI({ + apiKey: "not-a-real-api-key", + baseURL: `${apiUrl}/v1`, + dangerouslyAllowBrowser: true, + fetch: (...args) => { + customFetchInvocations += 1; + return customFetch(...args); }, - { - url: "https://example.test/v1/responses?model=private&stream=true", - method: "POST", - safeHeader: "preserve-me", - plaintext: '{"prompt":"original"}', - cache: "no-store", - credentials: "include", - redirect: "manual", - referrerPolicy: "no-referrer", - sameSignal: true - } - ]); - }); - - test("an abort after the first response prevents recovery and replay", async () => { - const controller = new AbortController(); - let requests = 0; - let forcedAttestations = 0; - const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key" }, - dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) forcedAttestations += 1; - return staleAttestation; - }, - fetch: async () => { - requests += 1; - controller.abort(); - return contractError(400, "stale", "session_not_found"); - } - }) - ); + maxRetries: 1 + }); - await expect( - customFetch("https://example.test/v1/responses", { signal: controller.signal }) - ).rejects.toMatchObject({ name: "AbortError" }); - expect(requests).toBe(1); - expect(forcedAttestations).toBe(0); + let rejected: unknown; + try { + await openai.chat.completions.create({ + model: "test-model", + messages: [{ role: "user", content: "hello" }] + }); + } catch (error) { + rejected = error; + } + expect(rejected).toBeInstanceOf(Error); + expect((rejected as Error).message).toContain("Connection error"); + expect(customFetchInvocations).toBe(2); + expect(transportSends).toBe(1); }); - test("a pre-aborted request performs no attestation or transport work", async () => { - const controller = new AbortController(); - controller.abort(); - let attestations = 0; - let requests = 0; + test("performs no transport work for a pre-aborted request", async () => { + const clientFetch = mock(async () => Response.json({ ok: true })); const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key" }, - dependencies({ - getAttestation: async () => { - attestations += 1; - return staleAttestation; - }, - fetch: async () => { - requests += 1; - return Response.json({}); - } - }) + { apiKey: "key", apiUrl }, + dependencies(clientFetch) ); - - await expect( - customFetch("https://example.test/v1/responses", { signal: controller.signal }) - ).rejects.toMatchObject({ name: "AbortError" }); - expect(attestations).toBe(0); - expect(requests).toBe(0); - }); - - test("an abort during initial attestation prevents the first transport send", async () => { const controller = new AbortController(); - let attestations = 0; - let requests = 0; - const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key" }, - dependencies({ - getAttestation: async () => { - attestations += 1; - controller.abort(); - return staleAttestation; - }, - fetch: async () => { - requests += 1; - return Response.json({}); - } - }) - ); + controller.abort(); await expect( - customFetch("https://example.test/v1/responses", { signal: controller.signal }) + customFetch(`${apiUrl}/v1/responses`, { method: "POST", signal: controller.signal }) ).rejects.toMatchObject({ name: "AbortError" }); - expect(attestations).toBe(1); - expect(requests).toBe(0); + expect(clientFetch).toHaveBeenCalledTimes(0); }); - test("an explicit null signal detaches from a Request source across recovery", async () => { - const sourceController = new AbortController(); - const sourceRequest = new Request("https://example.test/v1/responses", { - method: "POST", - body: '{"prompt":"detached"}', - signal: sourceController.signal + test("snapshots API-key authority before asynchronous body capture", async () => { + const options = { apiKey: "first-key", apiUrl }; + const deps = dependencies(async (input) => { + expect(input.authority).toEqual({ kind: "api_key", value: "first-key" }); + return Response.json({ ok: true }); }); - let currentAttestation = staleAttestation; - let forcedAttestations = 0; - const signals: Array = []; - let requests = 0; - const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key" }, - dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) { - forcedAttestations += 1; - sourceController.abort(); - currentAttestation = freshAttestation; - } - return currentAttestation; - }, - fetch: async (_input, init) => { - requests += 1; - signals.push(init?.signal); - return requests === 1 - ? contractError(400, "stale", "session_not_found") - : Response.json({ encrypted: '2:{"ok":true}' }); - } - }) - ); - - const response = await customFetch(sourceRequest, { signal: null }); - expect(await response.json()).toEqual({ ok: true }); - expect(sourceController.signal.aborted).toBe(true); - expect(forcedAttestations).toBe(1); - expect(requests).toBe(2); - expect(signals).toEqual([null, null]); + const customFetch = createCustomFetchWithDependencies(options, deps); + const request = customFetch(`${apiUrl}/v1/responses`, { method: "POST", body: "{}" }); + options.apiKey = "second-key"; + await request; }); - test("an explicit undefined signal inherits a Request source signal", async () => { - const sourceController = new AbortController(); - const sourceRequest = new Request("https://example.test/v1/responses", { - signal: sourceController.signal - }); - let attestations = 0; - let requests = 0; - const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key" }, - dependencies({ - getAttestation: async () => { - attestations += 1; - sourceController.abort(); - return staleAttestation; - }, - fetch: async () => { - requests += 1; - return Response.json({}); - } - }) + test("snapshots the exact user generation before asynchronous body capture", async () => { + const first = installTransportV2Credentials( + apiUrl, + "user", + token("access_descriptor"), + token("resumption") ); - - await expect(customFetch(sourceRequest, { signal: undefined })).rejects.toMatchObject({ - name: "AbortError" + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; }); - expect(attestations).toBe(1); - expect(requests).toBe(0); - }); - - test("keeps a stale retry bound to the identity that initiated the request", async () => { - window.localStorage.setItem("access_token", "initiating-account-token"); - let currentAttestation = staleAttestation; - const requests: RecordedRequest[] = []; - - const customFetch = createCustomFetchWithDependencies( - undefined, - dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) { - // Simulate an unrelated account change while re-attestation is in - // flight. The pending operation must retain its original token. - window.localStorage.setItem("access_token", "different-account-token"); - currentAttestation = freshAttestation; - } - return currentAttestation; - }, - fetch: async (_input, init) => { - const request = recordRequest(init); - requests.push(request); - if (request.sessionId === staleAttestation.sessionId) { - return contractError(400, "stale", "session_not_found"); - } - return Response.json({ encrypted: '2:{"ok":true}' }); - } - }) - ); - - const response = await customFetch("https://example.test/v1/responses", { - method: "POST", - body: '{"prompt":"hello"}' + const body = new ReadableStream({ + async start(controller) { + await gate; + controller.enqueue(new TextEncoder().encode("{}")); + controller.close(); + } }); - - expect(await response.json()).toEqual({ ok: true }); - expect(requests.map(({ authorization }) => authorization)).toEqual([ - "Bearer initiating-account-token", - "Bearer initiating-account-token" - ]); - expect(window.localStorage.getItem("access_token")).toBe("different-account-token"); - }); - - test("decrypts a retried SSE response with the fresh session key", async () => { - let currentAttestation = staleAttestation; - - const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key" }, - dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) currentAttestation = freshAttestation; - return currentAttestation; - }, - fetch: async (_input, init) => { - if (recordRequest(init).sessionId === staleAttestation.sessionId) { - return contractError(400, "stale", "session_not_found"); - } - - return new Response( - 'event: response.output_text.delta\ndata: 2:{"delta":"hello"}\n\ndata: [DONE]\n\n', - { headers: { "content-type": "text/event-stream" } } - ); - } - }) - ); - - const response = await customFetch("https://example.test/v1/responses", { - method: "POST", - body: '{"prompt":"hello"}' + const deps = dependencies(async (input) => { + expect(input.authority).toEqual({ + kind: "user", + principalId: first.principalId, + generation: first.generation + }); + return Response.json({ ok: true }); }); - - const responseText = await response.text(); - expect(responseText).toContain('data: {"delta":"hello"}'); - expect(responseText).toContain("data: [DONE]"); - expect(responseText).not.toContain('data: 2:{"delta":"hello"}'); + const customFetch = createCustomFetchWithDependencies({ apiUrl }, deps); + const request = new Request(`${apiUrl}/v1/responses`, { + method: "POST", + body, + duplex: "half" + } as RequestInit & { duplex: "half" }); + const pending = customFetch(request); + installTransportV2Credentials(apiUrl, "user", token("access_descriptor"), token("resumption")); + release(); + await pending; }); - test("shares one attestation renewal across concurrent stale requests", async () => { - let currentAttestation = staleAttestation; - let forcedAttestations = 0; - const requests: RecordedRequest[] = []; - - const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key" }, - dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) { - forcedAttestations += 1; - await new Promise((resolve) => setTimeout(resolve, 10)); - currentAttestation = freshAttestation; - } - return currentAttestation; - }, - fetch: async (_input, init) => { - const request = recordRequest(init); - requests.push(request); - if (request.sessionId === staleAttestation.sessionId) { - return contractError(400, "stale", "session_not_found"); - } - return Response.json({ encrypted: '2:{"ok":true}' }); - } - }) + test("restores the existing binary TTS response contract", async () => { + const deps = dependencies(async () => + Response.json({ content_base64: "AAEC/w==", content_type: "audio/mpeg" }) ); - - const results = await Promise.all( - Array.from({ length: 8 }, (_, index) => - customFetch("https://example.test/v1/responses", { - method: "POST", - body: JSON.stringify({ prompt: `hello-${index}` }) - }).then((response) => response.json()) - ) - ); - - expect(results).toEqual(Array.from({ length: 8 }, () => ({ ok: true }))); - expect(forcedAttestations).toBe(1); - expect(requests.filter(({ sessionId }) => sessionId === "stale-session")).toHaveLength(8); - expect(requests.filter(({ sessionId }) => sessionId === "fresh-session")).toHaveLength(8); - expect( - requests - .filter(({ sessionId }) => sessionId === "fresh-session") - .every(({ encryptedBody }) => encryptedBody?.startsWith("2:") === true) - ).toBe(true); - }); - - test("a staggered stale response joins renewal after the leader evicts the cache", async () => { - const lateKey = new Uint8Array(32).fill(3); - const lateAttestation: Attestation = { - sessionKey: lateKey, - sessionId: "late-extra-session" - }; - let currentAttestation: Attestation | null = staleAttestation; - let forcedAttestations = 0; - let fullHandshakes = 0; - let staleSends = 0; - let releaseCacheCleared!: () => void; - const cacheCleared = new Promise((resolve) => { - releaseCacheCleared = resolve; - }); - let releaseLateResponse!: () => void; - const lateResponseReturned = new Promise((resolve) => { - releaseLateResponse = resolve; + const customFetch = createCustomFetchWithDependencies({ apiKey: "key", apiUrl }, deps); + const response = await customFetch(`${apiUrl}/v1/audio/speech`, { + method: "POST", + body: "{}" }); - const requests: RecordedRequest[] = []; - - const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key" }, - dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) { - forcedAttestations += 1; - fullHandshakes += 1; - currentAttestation = null; - releaseCacheCleared(); - await lateResponseReturned; - await new Promise((resolve) => setTimeout(resolve, 10)); - currentAttestation = freshAttestation; - return freshAttestation; - } - if (currentAttestation) return currentAttestation; - - // This is the cache-miss handshake the old compare-before-map - // ordering allowed the staggered caller to start. - fullHandshakes += 1; - currentAttestation = lateAttestation; - return lateAttestation; - }, - fetch: async (_input, init) => { - const request = recordRequest(init); - requests.push(request); - if (request.sessionId === staleAttestation.sessionId) { - staleSends += 1; - if (staleSends === 2) { - await cacheCleared; - releaseLateResponse(); - } - return contractError(400, "stale", "session_not_found"); - } - const key = request.sessionId === freshAttestation.sessionId ? freshKey : lateKey; - return Response.json({ encrypted: encryptForTest(key, JSON.stringify({ ok: true })) }); - } - }) - ); - - const results = await Promise.all( - ["first", "late"].map((prompt) => - customFetch("https://example.test/v1/responses", { - method: "POST", - body: JSON.stringify({ prompt }) - }).then((response) => response.json()) - ) - ); - - expect(results).toEqual([{ ok: true }, { ok: true }]); - expect(forcedAttestations).toBe(1); - expect(fullHandshakes).toBe(1); - expect(requests.filter(({ sessionId }) => sessionId === "stale-session")).toHaveLength(2); - expect(requests.filter(({ sessionId }) => sessionId === "fresh-session")).toHaveLength(2); - expect(requests.some(({ sessionId }) => sessionId === "late-extra-session")).toBe(false); - }); - - test("does not replay non-400 application errors", async () => { - let requests = 0; - let forcedAttestations = 0; - - const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key" }, - dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) forcedAttestations += 1; - return staleAttestation; - }, - fetch: async () => { - requests += 1; - return contractError(422, "invalid request"); - } - }) - ); - - await expect(customFetch("https://example.test/v1/responses")).rejects.toThrow( - "Request failed with status 422: invalid request" - ); - expect(requests).toBe(1); - expect(forcedAttestations).toBe(0); - }); - - test("preserves coded application error metadata without replay", async () => { - let requests = 0; - const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key" }, - dependencies({ - fetch: async () => { - requests += 1; - return contractError( - 503, - '{"status":503,"message":"Upstream provider temporarily unavailable"}', - "image_description_unavailable" - ); - } - }) - ); - - let thrown: unknown; - try { - await customFetch("https://example.test/v1/responses"); - } catch (error) { - thrown = error; - } - - expect(requests).toBe(1); - expect(thrown).toBeInstanceOf(Error); - const requestError = thrown as Error & { status?: number; headers?: Headers }; - expect(requestError.message).toBe( - 'Request failed with status 503: {"status":503,"message":"Upstream provider temporarily unavailable"}' - ); - expect(requestError.status).toBe(503); - expect(requestError.headers?.get(ERROR_CONTRACT_HEADER)).toBe("1"); - expect(requestError.headers?.get(ERROR_CODE_HEADER)).toBe("image_description_unavailable"); - }); - - test("forwards one endpoint-bound PCR policy through lookup and renewal", async () => { - const apiUrl = "https://enclave.example.test/base"; - const pcrConfig: PcrConfig = { - environment: "development", - pcr0DevValues: ["2a".repeat(48)], - remoteAttestation: false - }; - const expectedPcrConfig: PcrConfig = { - environment: "development", - pcr0Values: [], - pcr0DevValues: ["2a".repeat(48)], - remoteAttestation: false, - remoteAttestationUrls: { - prod: "https://raw.githubusercontent.com/OpenSecretCloud/opensecret/master/pcrProdHistory.json", - dev: "https://raw.githubusercontent.com/OpenSecretCloud/opensecret/master/pcrDevHistory.json" - } - }; - const calls: Array<[boolean | undefined, string | undefined, PcrConfig | undefined]> = []; - let currentAttestation = staleAttestation; - - const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key", apiUrl, pcrConfig }, - dependencies({ - getAttestation: async (forceRefresh, explicitApiUrl, policy) => { - calls.push([forceRefresh, explicitApiUrl, policy]); - if (forceRefresh) currentAttestation = freshAttestation; - return currentAttestation; - }, - fetch: async (_input, init) => { - if (recordRequest(init).sessionId === staleAttestation.sessionId) { - pcrConfig.environment = "production"; - pcrConfig.pcr0DevValues = ["4c".repeat(48)]; - pcrConfig.remoteAttestation = true; - return contractError(400, "stale", "session_not_found"); - } - return Response.json({ encrypted: '2:{"ok":true}' }); - } - }) - ); - - const response = await customFetch("https://example.test/v1/responses"); - - expect(await response.json()).toEqual({ ok: true }); - expect(calls).toHaveLength(3); - expect(calls.map(([forceRefresh]) => forceRefresh)).toEqual([false, false, true]); - expect(calls.every(([, endpoint]) => endpoint === apiUrl)).toBe(true); - expect(calls.every(([, , policy]) => policy !== pcrConfig)).toBe(true); - expect(calls.map(([, , policy]) => policy)).toEqual([ - expectedPcrConfig, - expectedPcrConfig, - expectedPcrConfig - ]); - expect(calls[0][2]).toBe(calls[1][2]); - expect(calls[1][2]).toBe(calls[2][2]); - }); - - test("inherits the provider's global endpoint and PCR policy when options omit them", async () => { - const originalApiUrl = getApiUrl(); - const originalPcrConfig = getApiPcrConfig(); - const apiUrl = "https://provider.example.test"; - const pcrConfig: PcrConfig = { - pcr0Values: ["3b".repeat(48)], - remoteAttestation: false - }; - const calls: Array<[boolean | undefined, string | undefined, PcrConfig | undefined]> = []; - - try { - setApiUrl(apiUrl, pcrConfig); - const customFetch = createCustomFetchWithDependencies( - { apiKey: "test-api-key" }, - dependencies({ - getAttestation: async (forceRefresh, explicitApiUrl, policy) => { - calls.push([forceRefresh, explicitApiUrl, policy]); - return freshAttestation; - }, - fetch: async () => Response.json({ encrypted: '2:{"ok":true}' }) - }) - ); - - expect(await (await customFetch("https://example.test/v1/responses")).json()).toEqual({ - ok: true - }); - expect(calls).toHaveLength(1); - expect(calls[0][0]).toBe(false); - expect(calls[0][1]).toBe(apiUrl); - expect(calls[0][2]).toEqual(expect.objectContaining(pcrConfig)); - expect(calls[0][2]?.environment).toBe("production"); - } finally { - setApiUrl(originalApiUrl, originalPcrConfig); - } + expect(response.headers.get("content-type")).toBe("audio/mpeg"); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array([0, 1, 2, 0xff])); }); }); diff --git a/sdk/src/lib/test/encryptedApi.test.ts b/sdk/src/lib/test/encryptedApi.test.ts index 281e8f44e..828e23653 100644 --- a/sdk/src/lib/test/encryptedApi.test.ts +++ b/sdk/src/lib/test/encryptedApi.test.ts @@ -1,603 +1,245 @@ -import { beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { authenticatedApiCallWithDependencies, encryptedApiCallWithDependencies, openAiAuthenticatedApiCallWithDependencies, type EncryptedApiDependencies } from "../encryptedApi"; -import type { Attestation } from "../getAttestation"; -import { snapshotPcrConfig } from "../pcr"; -import { ERROR_CODE_HEADER, ERROR_CONTRACT_HEADER } from "../recovery"; - -const staleKey = new Uint8Array(32).fill(1); -const freshKey = new Uint8Array(32).fill(2); -const staleAttestation: Attestation = { sessionKey: staleKey, sessionId: "stale-session" }; -const freshAttestation: Attestation = { sessionKey: freshKey, sessionId: "fresh-session" }; - -function encryptForTest(sessionKey: Uint8Array, plaintext: string): string { - return `${sessionKey[0]}:${plaintext}`; +import type { TransportV2FetchInput } from "../transportV2/client"; +import { clearTransportV2Credentials, installTransportV2Credentials } from "../transportV2/auth"; + +const appUrl = "https://app.example.test"; +const platformUrl = "https://platform.example.test"; + +function token( + kind: "user" | "platform", + tokenKind: "access_descriptor" | "resumption", + principalId: string +): string { + const audience = `urn:opensecret:internal:transport-v2:${kind}:${ + tokenKind === "access_descriptor" ? "access-descriptor" : "resumption" + }`; + const payload = Buffer.from( + JSON.stringify({ + iss: "urn:opensecret:transport-v2", + aud: audience, + tv: 2, + tk: tokenKind, + pk: kind, + sub: principalId, + exp: 2_000_000_000 + }) + ).toString("base64url"); + return `e30.${payload}.c2ln`; } -function decryptForTest(sessionKey: Uint8Array, ciphertext: string): string { - const prefix = `${sessionKey[0]}:`; - if (!ciphertext.startsWith(prefix)) throw new Error(`wrong key ${sessionKey[0]}`); - return ciphertext.slice(prefix.length); +function install(kind: "user" | "platform", apiUrl: string, principalId: string): void { + installTransportV2Credentials( + apiUrl, + kind, + token(kind, "access_descriptor", principalId), + token(kind, "resumption", principalId) + ); } -function contractError(status: number, message: string, code?: string): Response { - const headers = new Headers({ [ERROR_CONTRACT_HEADER]: "1" }); - if (code) headers.set(ERROR_CODE_HEADER, code); - return Response.json({ status, message }, { status, headers }); -} +beforeEach(() => { + install("user", appUrl, "user-123"); + install("platform", platformUrl, "platform-123"); +}); -function encryptedSuccess(sessionKey: Uint8Array, value: unknown): Response { - return Response.json( - { encrypted: encryptForTest(sessionKey, JSON.stringify(value)) }, - { headers: { [ERROR_CONTRACT_HEADER]: "1" } } - ); -} +afterEach(() => { + clearTransportV2Credentials(appUrl); + clearTransportV2Credentials(platformUrl); + localStorage.clear(); +}); -function dependencies(overrides: Partial = {}): EncryptedApiDependencies { +function dependencies( + implementation: (input: TransportV2FetchInput) => Promise +): EncryptedApiDependencies { return { - decryptMessage: decryptForTest, - encryptMessage: encryptForTest, - fetch: async () => new Response(null, { status: 500 }), - getAttestation: async () => staleAttestation, - getApiPcrConfig: () => snapshotPcrConfig({ environment: "development" }), - getApiUrl: () => "https://api.example.test", - getPlatformApiUrl: () => "https://platform.example.test", - getPlatformPcrConfig: () => snapshotPcrConfig({ environment: "development" }), - getAccessToken: () => window.localStorage.getItem("access_token"), - refreshAccessToken: async () => {}, - resolveEndpoint: (url) => ({ - baseUrl: "https://api.example.test", - context: url.includes("/platform/") ? "platform" : "app" - }), - ...overrides + client: { fetch: mock(implementation) }, + getApiUrl: () => appUrl, + getApiPcrConfig: () => ({ environment: "development" }), + getPlatformApiUrl: () => platformUrl, + getPlatformPcrConfig: () => ({ environment: "development" }) }; } -function recordedRequest(init: RequestInit | undefined, sessionKey: Uint8Array) { - const headers = new Headers(init?.headers); - const envelope = init?.body - ? (JSON.parse(String(init.body)) as { encrypted: string }) - : undefined; - return { - authorization: headers.get("Authorization"), - method: init?.method, - plaintext: envelope ? decryptForTest(sessionKey, envelope.encrypted) : undefined, - sessionId: headers.get("x-session-id") - }; -} +describe("typed transport v2 API adapter", () => { + test("binds login to an anonymous user session with no outer Authorization", async () => { + const deps = dependencies(async (input) => { + expect(input).toMatchObject({ + apiUrl: appUrl, + url: `${appUrl}/login`, + method: "POST", + responseMode: "unary", + authority: { kind: "anonymous", purpose: "user" } + }); + expect(new Headers(input.headers).get("content-type")).toBe("application/json"); + expect(new Headers(input.headers).has("authorization")).toBe(false); + expect(JSON.parse(new TextDecoder().decode(input.body!))).toEqual({ + email: "person@example.test", + password: "secret" + }); + return Response.json({ access_token: "descriptor", refresh_token: "resumption" }); + }); -describe("encrypted API recovery", () => { - beforeEach(() => { - window.localStorage.clear(); - window.sessionStorage.clear(); + await expect( + encryptedApiCallWithDependencies( + `${appUrl}/login`, + "POST", + { email: "person@example.test", password: "secret" }, + undefined, + undefined, + deps + ) + ).resolves.toEqual({ access_token: "descriptor", refresh_token: "resumption" }); }); - test("v1 session recovery re-encrypts one exact typed request", async () => { - let currentAttestation = staleAttestation; - let forcedAttestations = 0; - const data = { operation: "original" }; - const urls: string[] = []; - const requests: ReturnType[] = []; - const deps = dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) { - forcedAttestations += 1; - currentAttestation = freshAttestation; - } - return currentAttestation; - }, - fetch: async (input, init) => { - urls.push(String(input)); - const key = - new Headers(init?.headers).get("x-session-id") === "stale-session" ? staleKey : freshKey; - requests.push(recordedRequest(init, key)); - if (requests.length === 1) { - data.operation = "mutated"; - return contractError(400, "Bad Request", "session_not_found"); - } - return encryptedSuccess(freshKey, { ok: true }); - } + test("keeps app and platform bound authorities separate", async () => { + const seen: TransportV2FetchInput[] = []; + const deps = dependencies(async (input) => { + seen.push(input); + return Response.json({ ok: true }); }); - const result = await encryptedApiCallWithDependencies<{ operation: string }, { ok: boolean }>( - "https://api.example.test/protected/action?mode=exact", - "PATCH", - data, - "api-key", + await authenticatedApiCallWithDependencies( + `${appUrl}/protected/user`, + "GET", + undefined, undefined, deps ); - - expect(result).toEqual({ ok: true }); - expect(forcedAttestations).toBe(1); - expect(urls).toEqual([ - "https://api.example.test/protected/action?mode=exact", - "https://api.example.test/protected/action?mode=exact" - ]); - expect(requests).toEqual([ - { - authorization: "Bearer api-key", - method: "PATCH", - plaintext: '{"operation":"original"}', - sessionId: "stale-session" - }, - { - authorization: "Bearer api-key", - method: "PATCH", - plaintext: '{"operation":"original"}', - sessionId: "fresh-session" - } - ]); - }); - - test("shares one session renewal across concurrent typed stale requests", async () => { - const callCount = 8; - let currentAttestation = staleAttestation; - let forcedAttestations = 0; - let attestationDocuments = 0; - let keyExchanges = 0; - let staleSends = 0; - let releaseStaleResponses!: () => void; - const allStaleRequestsStarted = new Promise((resolve) => { - releaseStaleResponses = resolve; - }); - const requests: Array< - ReturnType & { - ciphertext: string | undefined; - } - > = []; - - const deps = dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) { - forcedAttestations += 1; - attestationDocuments += 1; - await new Promise((resolve) => setTimeout(resolve, 10)); - keyExchanges += 1; - currentAttestation = freshAttestation; - } - return currentAttestation; - }, - fetch: async (_input, init) => { - const sessionId = new Headers(init?.headers).get("x-session-id"); - const key = sessionId === freshAttestation.sessionId ? freshKey : staleKey; - const envelope = init?.body - ? (JSON.parse(String(init.body)) as { encrypted: string }) - : undefined; - const request = { - ...recordedRequest(init, key), - ciphertext: envelope?.encrypted - }; - requests.push(request); - - if (sessionId === staleAttestation.sessionId) { - staleSends += 1; - if (staleSends === callCount) releaseStaleResponses(); - await allStaleRequestsStarted; - return contractError(400, "Bad Request", "session_not_found"); - } - - const payload = JSON.parse(request.plaintext || "{}") as { operation?: string }; - return encryptedSuccess(freshKey, { operation: payload.operation }); - } - }); - - const results = await Promise.all( - Array.from({ length: callCount }, (_, index) => - encryptedApiCallWithDependencies<{ operation: string }, { operation: string }>( - "https://api.example.test/protected/action", - "POST", - { operation: `call-${index}` }, - "api-key", - undefined, - deps - ) - ) - ); - - expect(results).toEqual( - Array.from({ length: callCount }, (_, index) => ({ operation: `call-${index}` })) - ); - expect(forcedAttestations).toBe(1); - expect(attestationDocuments).toBe(1); - expect(keyExchanges).toBe(1); - expect(requests.filter(({ sessionId }) => sessionId === "stale-session")).toHaveLength( - callCount - ); - const freshRequests = requests.filter(({ sessionId }) => sessionId === "fresh-session"); - expect(freshRequests).toHaveLength(callCount); - expect(freshRequests.every(({ ciphertext }) => ciphertext?.startsWith("2:") === true)).toBe( - true - ); - expect(freshRequests.map(({ plaintext }) => plaintext).sort()).toEqual( - Array.from({ length: callCount }, (_, index) => `{"operation":"call-${index}"}`).sort() - ); - }); - - test("a staggered typed stale response joins after the leader clears the cache", async () => { - const lateKey = new Uint8Array(32).fill(3); - const lateAttestation: Attestation = { - sessionKey: lateKey, - sessionId: "late-extra-session" - }; - let currentAttestation: Attestation | null = staleAttestation; - let forcedAttestations = 0; - let fullHandshakes = 0; - let staleSends = 0; - let releaseCacheCleared!: () => void; - const cacheCleared = new Promise((resolve) => { - releaseCacheCleared = resolve; - }); - let releaseLateResponse!: () => void; - const lateResponseReturned = new Promise((resolve) => { - releaseLateResponse = resolve; - }); - const sessionIds: Array = []; - - const deps = dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) { - forcedAttestations += 1; - fullHandshakes += 1; - currentAttestation = null; - releaseCacheCleared(); - await lateResponseReturned; - await new Promise((resolve) => setTimeout(resolve, 10)); - currentAttestation = freshAttestation; - return freshAttestation; - } - if (currentAttestation) return currentAttestation; - - fullHandshakes += 1; - currentAttestation = lateAttestation; - return lateAttestation; - }, - fetch: async (_input, init) => { - const sessionId = new Headers(init?.headers).get("x-session-id"); - sessionIds.push(sessionId); - if (sessionId === staleAttestation.sessionId) { - staleSends += 1; - if (staleSends === 2) { - await cacheCleared; - releaseLateResponse(); - } - return contractError(400, "Bad Request", "session_not_found"); - } - - const key = sessionId === freshAttestation.sessionId ? freshKey : lateKey; - return encryptedSuccess(key, { ok: true }); - } - }); - - const results = await Promise.all( - ["first", "late"].map((operation) => - encryptedApiCallWithDependencies<{ operation: string }, { ok: boolean }>( - "https://api.example.test/protected/action", - "POST", - { operation }, - "api-key", - undefined, - deps - ) - ) + await authenticatedApiCallWithDependencies( + `${platformUrl}/platform/me`, + "GET", + undefined, + undefined, + deps ); - expect(results).toEqual([{ ok: true }, { ok: true }]); - expect(forcedAttestations).toBe(1); - expect(fullHandshakes).toBe(1); - expect(sessionIds.filter((sessionId) => sessionId === "stale-session")).toHaveLength(2); - expect(sessionIds.filter((sessionId) => sessionId === "fresh-session")).toHaveLength(2); - expect(sessionIds).not.toContain("late-extra-session"); + expect(seen.map((input) => input.authority)).toEqual([ + expect.objectContaining({ kind: "user", principalId: "user-123" }), + expect.objectContaining({ kind: "platform", principalId: "platform-123" }) + ]); + expect(seen.every((input) => input.body === null)).toBe(true); }); - test("v1 access-token expiry refreshes once and replays with the new token", async () => { - window.localStorage.setItem("access_token", "expired-access-token"); - let tokenRefreshes = 0; - const authorizations: Array = []; - const deps = dependencies({ - refreshAccessToken: async () => { - tokenRefreshes += 1; - window.localStorage.setItem("access_token", "fresh-access-token"); - }, - fetch: async (_input, init) => { - authorizations.push(new Headers(init?.headers).get("Authorization")); - return authorizations.length === 1 - ? contractError(401, "Invalid JWT", "access_token_expired") - : encryptedSuccess(staleKey, { ok: true }); - } + test("hands an explicit API key only to the v2 authority binder", async () => { + const deps = dependencies(async (input) => { + expect(input.authority).toEqual({ kind: "api_key", value: "raw-api-key" }); + expect(new Headers(input.headers).has("authorization")).toBe(false); + return Response.json({ ok: true }); }); - expect( - await authenticatedApiCallWithDependencies( - "https://api.example.test/protected/user", - "GET", - undefined, + await expect( + openAiAuthenticatedApiCallWithDependencies( + `${appUrl}/v1/audio/transcriptions`, + "POST", + { audio: "bytes" }, undefined, + "raw-api-key", deps ) - ).toEqual({ ok: true }); - expect(tokenRefreshes).toBe(1); - expect(authorizations).toEqual(["Bearer expired-access-token", "Bearer fresh-access-token"]); + ).resolves.toEqual({ ok: true }); }); - for (const ordinary of [ - { status: 400, code: undefined, message: "Encryption error" }, - { status: 401, code: "invalid_jwt", message: "Invalid JWT" } - ]) { - test(`v1 ordinary ${ordinary.status} fails closed`, async () => { - window.localStorage.setItem("access_token", "access-token"); - let sends = 0; - let forcedAttestations = 0; - let tokenRefreshes = 0; - const deps = dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) forcedAttestations += 1; - return staleAttestation; - }, - refreshAccessToken: async () => { - tokenRefreshes += 1; - }, - fetch: async () => { - sends += 1; - return contractError(ordinary.status, ordinary.message, ordinary.code); - } - }); - - await expect( - authenticatedApiCallWithDependencies( - "https://api.example.test/protected/user", - "GET", - undefined, - undefined, - deps - ) - ).rejects.toThrow(ordinary.message); - expect(sends).toBe(1); - expect(forcedAttestations).toBe(0); - expect(tokenRefreshes).toBe(0); - }); - } - - test("headerless 400 and 401 retain legacy recovery", async () => { - window.localStorage.setItem("access_token", "expired-access-token"); - let currentAttestation = staleAttestation; - let sessionSends = 0; - let authSends = 0; - let forcedAttestations = 0; - let tokenRefreshes = 0; - const deps = dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) { - forcedAttestations += 1; - currentAttestation = freshAttestation; - } - return currentAttestation; - }, - refreshAccessToken: async () => { - tokenRefreshes += 1; - window.localStorage.setItem("access_token", "fresh-access-token"); - }, - fetch: async (input) => { - if (String(input).endsWith("/legacy-session")) { - sessionSends += 1; - return sessionSends === 1 - ? Response.json({ message: "Bad Request" }, { status: 400 }) - : encryptedSuccess(freshKey, { ok: "session" }); - } - authSends += 1; - return authSends === 1 - ? Response.json({ message: "Invalid JWT" }, { status: 401 }) - : encryptedSuccess(freshKey, { ok: "auth" }); - } + test("never transparently retries after the v2 manager may have sent", async () => { + let sends = 0; + const deps = dependencies(async () => { + sends += 1; + throw new Error("ambiguous network failure"); }); - expect( - await encryptedApiCallWithDependencies( - "https://api.example.test/legacy-session", - "GET", - undefined, - undefined, - undefined, - deps - ) - ).toEqual({ ok: "session" }); - expect( - await authenticatedApiCallWithDependencies( - "https://api.example.test/protected/legacy-auth", - "GET", + await expect( + authenticatedApiCallWithDependencies( + `${appUrl}/protected/kv`, + "DELETE", undefined, undefined, deps ) - ).toEqual({ ok: "auth" }); - expect(sessionSends).toBe(2); - expect(authSends).toBe(2); - expect(forcedAttestations).toBe(1); - expect(tokenRefreshes).toBe(1); + ).rejects.toThrow("ambiguous network failure"); + expect(sends).toBe(1); }); - test("one target replay budget stops alternating recovery reasons", async () => { - window.localStorage.setItem("access_token", "access-token"); - let currentAttestation = staleAttestation; - let sends = 0; - let forcedAttestations = 0; - let tokenRefreshes = 0; - const deps = dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) { - forcedAttestations += 1; - currentAttestation = freshAttestation; - } - return currentAttestation; - }, - refreshAccessToken: async () => { - tokenRefreshes += 1; - }, - fetch: async () => { - sends += 1; - return sends === 1 - ? contractError(400, "Bad Request", "session_not_found") - : contractError(401, "Invalid JWT", "access_token_expired"); - } - }); - + test("preserves authenticated error text and successful empty bodies", async () => { + const rejected = dependencies(async () => + Response.json({ message: "bound request rejected" }, { status: 409 }) + ); await expect( - authenticatedApiCallWithDependencies( - "https://api.example.test/protected/action", - "POST", + authenticatedApiCallWithDependencies( + `${appUrl}/protected/kv`, + "GET", undefined, undefined, - deps + rejected ) - ).rejects.toThrow("Invalid JWT"); - expect(sends).toBe(2); - expect(forcedAttestations).toBe(1); - expect(tokenRefreshes).toBe(0); - }); - - test("expired target JWT can refresh through one stale-session repair", async () => { - window.localStorage.setItem("access_token", "expired-access-token"); - let currentAttestation = staleAttestation; - let forcedAttestations = 0; - let targetSends = 0; - let refreshSends = 0; - const targetRequests: ReturnType[] = []; - const refreshRequests: ReturnType[] = []; - let deps!: EncryptedApiDependencies; - - deps = dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) { - forcedAttestations += 1; - currentAttestation = freshAttestation; - } - return currentAttestation; - }, - refreshAccessToken: async () => { - const tokens = await encryptedApiCallWithDependencies< - { refresh_token: string }, - { access_token: string; refresh_token: string } - >( - "https://api.example.test/refresh", - "POST", - { refresh_token: "refresh-token" }, - undefined, - undefined, - deps - ); - window.localStorage.setItem("access_token", tokens.access_token); - }, - fetch: async (input, init) => { - const url = String(input); - const sessionId = new Headers(init?.headers).get("x-session-id"); - const key = sessionId === "fresh-session" ? freshKey : staleKey; - if (url.includes("/refresh")) { - refreshSends += 1; - refreshRequests.push(recordedRequest(init, key)); - return refreshSends === 1 - ? contractError(400, "Bad Request", "session_not_found") - : encryptedSuccess(freshKey, { - access_token: "fresh-access-token", - refresh_token: "fresh-refresh-token" - }); - } + ).rejects.toThrow("bound request rejected"); - targetSends += 1; - targetRequests.push(recordedRequest(init, key)); - return targetSends === 1 - ? contractError(401, "Invalid JWT", "access_token_expired") - : encryptedSuccess(freshKey, { ok: true }); - } - }); - - expect( - await authenticatedApiCallWithDependencies<{ prompt: string }, { ok: boolean }>( - "https://api.example.test/v1/chat/completions?stream=false", - "POST", - { prompt: "same prompt" }, + const empty = dependencies(async () => new Response(null, { status: 204 })); + await expect( + authenticatedApiCallWithDependencies( + `${appUrl}/protected/kv`, + "DELETE", undefined, - deps + undefined, + empty ) - ).toEqual({ ok: true }); - expect(targetSends).toBe(2); - expect(refreshSends).toBe(2); - expect(forcedAttestations).toBe(1); - expect(refreshRequests.map(({ plaintext }) => plaintext)).toEqual([ - '{"refresh_token":"refresh-token"}', - '{"refresh_token":"refresh-token"}' - ]); - expect(targetRequests).toEqual([ - { - authorization: "Bearer expired-access-token", - method: "POST", - plaintext: '{"prompt":"same prompt"}', - sessionId: "stale-session" - }, - { - authorization: "Bearer fresh-access-token", - method: "POST", - plaintext: '{"prompt":"same prompt"}', - sessionId: "fresh-session" - } - ]); + ).resolves.toBeUndefined(); }); - test("a successful response decryption failure never replays", async () => { - let sends = 0; - let forcedAttestations = 0; - const deps = dependencies({ - getAttestation: async (forceRefresh) => { - if (forceRefresh) forcedAttestations += 1; - return staleAttestation; - }, - fetch: async () => { - sends += 1; - return Response.json({ encrypted: '2:{"ok":true}' }); - } + test("consumes the authenticated Responses stream to its completed object", async () => { + const completed = { + id: "response-id", + object: "response", + created_at: 1, + status: "completed", + model: "test-model" + }; + const deps = dependencies(async (input) => { + expect(input.responseMode).toBe("stream"); + return new Response( + `event: response.created\ndata: {"type":"response.created"}\n\n` + + `event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response: completed + })}\n\n`, + { headers: { "content-type": "text/event-stream" } } + ); }); await expect( - encryptedApiCallWithDependencies( - "https://api.example.test/action", + authenticatedApiCallWithDependencies( + `${appUrl}/v1/responses`, "POST", - undefined, - undefined, + { model: "test-model", input: "hello" }, undefined, deps ) - ).rejects.toThrow("Failed to decrypt or parse the response"); - expect(sends).toBe(1); - expect(forcedAttestations).toBe(0); + ).resolves.toEqual(completed); }); - test("API-key 401 never invokes access-token refresh", async () => { - let sends = 0; - let tokenRefreshes = 0; - const deps = dependencies({ - refreshAccessToken: async () => { - tokenRefreshes += 1; - }, - fetch: async () => { - sends += 1; - return contractError(401, "Invalid JWT", "access_token_expired"); - } - }); + test("maps a terminal Responses error to the typed thrown-error surface", async () => { + const deps = dependencies( + async () => + new Response( + `event: response.error\ndata: ${JSON.stringify({ + type: "response.error", + error: { message: "provider failed" } + })}\n\n`, + { headers: { "content-type": "text/event-stream" } } + ) + ); await expect( - openAiAuthenticatedApiCallWithDependencies( - "https://api.example.test/v1/models", - "GET", - undefined, + authenticatedApiCallWithDependencies( + `${appUrl}/v1/responses`, + "POST", + { model: "test-model", input: "hello" }, undefined, - "api-key", deps ) - ).rejects.toThrow("Invalid JWT"); - expect(sends).toBe(1); - expect(tokenRefreshes).toBe(0); + ).rejects.toThrow("provider failed"); }); }); diff --git a/sdk/src/lib/test/integration/platformPushSettings.test.ts b/sdk/src/lib/test/integration/platformPushSettings.test.ts index 7438ab808..d2a1fcb38 100644 --- a/sdk/src/lib/test/integration/platformPushSettings.test.ts +++ b/sdk/src/lib/test/integration/platformPushSettings.test.ts @@ -1,7 +1,4 @@ -import { afterEach, beforeEach, expect, mock, test } from "bun:test"; -import { decryptMessage, encryptMessage } from "../../encryption"; -import { cacheAttestationSessionForTesting } from "../../getAttestation"; -import type { PcrConfig } from "../../pcr"; +import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; import { getPlatformApiUrl, getPlatformPcrConfig, @@ -10,37 +7,62 @@ import { updatePushSettings, type PushSettings } from "../../platformApi"; +import type { PcrConfig } from "../../pcr"; +import { clearTransportV2Credentials, installTransportV2Credentials } from "../../transportV2/auth"; +import { transportV2Client, type TransportV2FetchInput } from "../../transportV2/client"; -const sessionKey = new Uint8Array(32).fill(7); -const sessionId = "push-settings-session-id"; -const accessToken = "push-settings-access-token"; const platformApiUrl = "https://platform.example.com"; -const verifiedPcr0 = - "eeddbb58f57c38894d6d5af5e575fbe791c5bf3bbcfb5df8da8cfcf0c2e1da1913108e6a762112444740b88c163d7f4b"; -const pcrConfig: PcrConfig = { pcr0Values: [verifiedPcr0], remoteAttestation: false }; - -const originalFetch = globalThis.fetch; +const pcrConfig: PcrConfig = { environment: "production", remoteAttestation: false }; const originalPlatformApiUrl = getPlatformApiUrl(); const originalPlatformPcrConfig = getPlatformPcrConfig(); -beforeEach(async () => { - window.localStorage.clear(); - window.sessionStorage.clear(); - window.localStorage.setItem("access_token", accessToken); +function platformToken(kind: "access_descriptor" | "resumption"): string { + const audience = + kind === "access_descriptor" + ? "urn:opensecret:internal:transport-v2:platform:access-descriptor" + : "urn:opensecret:internal:transport-v2:platform:resumption"; + const payload = Buffer.from( + JSON.stringify({ + iss: "urn:opensecret:transport-v2", + aud: audience, + tv: 2, + tk: kind, + pk: "platform", + sub: "platform-user-123", + exp: 2_000_000_000 + }) + ).toString("base64url"); + return `e30.${payload}.c2ln`; +} + +function expectPlatformRequest(input: TransportV2FetchInput, method: "GET" | "PUT"): void { + expect(input.url).toBe( + `${platformApiUrl}/platform/orgs/org-123/projects/project-456/settings/push` + ); + expect(input.method).toBe(method); + expect(input.authority).toMatchObject({ kind: "platform", principalId: "platform-user-123" }); + expect(input.authority).toHaveProperty("generation"); + expect(new Headers(input.headers).has("authorization")).toBe(false); +} + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); setPlatformApiUrl(platformApiUrl, pcrConfig); - await cacheAttestationSessionForTesting( + installTransportV2Credentials( platformApiUrl, - pcrConfig, - { sessionKey, sessionId }, - verifiedPcr0 + "platform", + platformToken("access_descriptor"), + platformToken("resumption") ); }); afterEach(() => { - globalThis.fetch = originalFetch; + mock.restore(); + clearTransportV2Credentials(platformApiUrl); setPlatformApiUrl(originalPlatformApiUrl, originalPlatformPcrConfig); - window.localStorage.clear(); - window.sessionStorage.clear(); + localStorage.clear(); + sessionStorage.clear(); }); test("getPushSettings calls the project push settings endpoint", async () => { @@ -59,31 +81,14 @@ test("getPushSettings calls the project push settings endpoint", async () => { package_name: "ai.trymaple.android" } }; + const fetch = spyOn(transportV2Client, "fetch").mockImplementation(async (input) => { + expectPlatformRequest(input, "GET"); + expect(input.body).toBeNull(); + return Response.json(responseSettings); + }); - globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => { - expect(input.toString()).toBe( - `${platformApiUrl}/platform/orgs/org-123/projects/project-456/settings/push` - ); - expect(init?.method).toBe("GET"); - expect(init?.headers).toMatchObject({ - Authorization: `Bearer ${accessToken}`, - "x-session-id": sessionId - }); - - return new Response( - JSON.stringify({ - encrypted: encryptMessage(sessionKey, JSON.stringify(responseSettings)) - }), - { - status: 200, - headers: { "Content-Type": "application/json" } - } - ); - }) as typeof fetch; - - const settings = await getPushSettings("org-123", "project-456"); - - expect(settings).toEqual(responseSettings); + await expect(getPushSettings("org-123", "project-456")).resolves.toEqual(responseSettings); + expect(fetch).toHaveBeenCalledTimes(1); }); test("updatePushSettings sends encrypted push settings to the project endpoint", async () => { @@ -102,35 +107,14 @@ test("updatePushSettings sends encrypted push settings to the project endpoint", package_name: "ai.trymaple.android" } }; + const fetch = spyOn(transportV2Client, "fetch").mockImplementation(async (input) => { + expectPlatformRequest(input, "PUT"); + expect(JSON.parse(new TextDecoder().decode(input.body!))).toEqual(requestSettings); + return Response.json(requestSettings); + }); - globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => { - expect(input.toString()).toBe( - `${platformApiUrl}/platform/orgs/org-123/projects/project-456/settings/push` - ); - expect(init?.method).toBe("PUT"); - expect(init?.headers).toMatchObject({ - Authorization: `Bearer ${accessToken}`, - "x-session-id": sessionId - }); - - const requestBody = JSON.parse(String(init?.body)) as { encrypted: string }; - const decryptedRequest = JSON.parse( - decryptMessage(sessionKey, requestBody.encrypted) - ) as PushSettings; - expect(decryptedRequest).toEqual(requestSettings); - - return new Response( - JSON.stringify({ - encrypted: encryptMessage(sessionKey, JSON.stringify(requestSettings)) - }), - { - status: 200, - headers: { "Content-Type": "application/json" } - } - ); - }) as typeof fetch; - - const settings = await updatePushSettings("org-123", "project-456", requestSettings); - - expect(settings).toEqual(requestSettings); + await expect(updatePushSettings("org-123", "project-456", requestSettings)).resolves.toEqual( + requestSettings + ); + expect(fetch).toHaveBeenCalledTimes(1); }); diff --git a/sdk/src/lib/test/integration/web.test.ts b/sdk/src/lib/test/integration/web.test.ts index aa00c7233..4b9653e65 100644 --- a/sdk/src/lib/test/integration/web.test.ts +++ b/sdk/src/lib/test/integration/web.test.ts @@ -1,7 +1,4 @@ -import { afterEach, beforeEach, expect, mock, test } from "bun:test"; -import { decryptMessage, encryptMessage } from "../../encryption"; -import { cacheAttestationSessionForTesting } from "../../getAttestation"; -import type { PcrConfig } from "../../pcr"; +import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; import { getApiPcrConfig, getApiUrl, @@ -13,39 +10,66 @@ import { type WebSearchRequest, type WebSearchResponse } from "../../api"; +import type { PcrConfig } from "../../pcr"; +import { clearTransportV2Credentials, installTransportV2Credentials } from "../../transportV2/auth"; +import { transportV2Client, type TransportV2FetchInput } from "../../transportV2/client"; const apiUrl = "https://api.example.com"; -const accessToken = "web-access-token"; -const sessionId = "web-session-id"; -const sessionKey = new Uint8Array(32).fill(19); -const verifiedPcr0 = - "eeddbb58f57c38894d6d5af5e575fbe791c5bf3bbcfb5df8da8cfcf0c2e1da1913108e6a762112444740b88c163d7f4b"; -const pcrConfig: PcrConfig = { pcr0Values: [verifiedPcr0], remoteAttestation: false }; -const originalFetch = globalThis.fetch; +const pcrConfig: PcrConfig = { environment: "production", remoteAttestation: false }; const originalApiUrl = getApiUrl(); const originalApiPcrConfig = getApiPcrConfig(); -beforeEach(async () => { - window.localStorage.clear(); - window.sessionStorage.clear(); - window.localStorage.setItem("access_token", accessToken); +function userToken(kind: "access_descriptor" | "resumption"): string { + const audience = + kind === "access_descriptor" + ? "urn:opensecret:internal:transport-v2:user:access-descriptor" + : "urn:opensecret:internal:transport-v2:user:resumption"; + const payload = Buffer.from( + JSON.stringify({ + iss: "urn:opensecret:transport-v2", + aud: audience, + tv: 2, + tk: kind, + pk: "user", + sub: "user-123", + exp: 2_000_000_000 + }) + ).toString("base64url"); + return `e30.${payload}.c2ln`; +} + +function expectWebRequest( + input: TransportV2FetchInput, + path: "/v1/web/search" | "/v1/web/extract" +): void { + expect(input.url).toBe(`${apiUrl}${path}`); + expect(input.method).toBe("POST"); + expect(input.authority).toMatchObject({ kind: "user", principalId: "user-123" }); + expect(input.authority).toHaveProperty("generation"); + expect(new Headers(input.headers).has("authorization")).toBe(false); +} + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); setApiUrl(apiUrl, pcrConfig); - await cacheAttestationSessionForTesting( + installTransportV2Credentials( apiUrl, - pcrConfig, - { sessionKey, sessionId }, - verifiedPcr0 + "user", + userToken("access_descriptor"), + userToken("resumption") ); }); afterEach(() => { - globalThis.fetch = originalFetch; + mock.restore(); + clearTransportV2Credentials(apiUrl); setApiUrl(originalApiUrl, originalApiPcrConfig); - window.localStorage.clear(); - window.sessionStorage.clear(); + localStorage.clear(); + sessionStorage.clear(); }); -test("webSearch sends an authenticated encrypted request and decrypts results", async () => { +test("webSearch sends a user-bound v2 request and reconstructs results", async () => { const request: WebSearchRequest = { query: "rust confidential computing", workflow: "news", @@ -59,9 +83,7 @@ test("webSearch sends an authenticated encrypted request and decrypts results", time_relative: "week", search_region: "US" }, - filters: { - region: "US" - } + filters: { region: "US" } }; const response: WebSearchResponse = { trace_id: "trace-search-1", @@ -75,25 +97,14 @@ test("webSearch sends an authenticated encrypted request and decrypts results", } ] }; - - globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => { - expect(input.toString()).toBe(`${apiUrl}/v1/web/search`); - expect(init?.method).toBe("POST"); - expect(init?.headers).toMatchObject({ - Authorization: `Bearer ${accessToken}`, - "x-session-id": sessionId - }); - - const body = JSON.parse(String(init?.body)) as { encrypted: string }; - expect(JSON.parse(decryptMessage(sessionKey, body.encrypted))).toEqual(request); - - return new Response( - JSON.stringify({ encrypted: encryptMessage(sessionKey, JSON.stringify(response)) }), - { status: 200, headers: { "Content-Type": "application/json" } } - ); - }) as typeof fetch; + const fetch = spyOn(transportV2Client, "fetch").mockImplementation(async (input) => { + expectWebRequest(input, "/v1/web/search"); + expect(JSON.parse(new TextDecoder().decode(input.body!))).toEqual(request); + return Response.json(response); + }); await expect(webSearch(request)).resolves.toEqual(response); + expect(fetch).toHaveBeenCalledTimes(1); }); test("webExtract preserves ordered pages and typed partial failures", async () => { @@ -104,68 +115,37 @@ test("webExtract preserves ordered pages and typed partial failures", async () = const response: WebExtractResponse = { trace_id: "trace-extract-1", pages: [ - { - url: request.urls[0], - markdown: "# First\n\nExtracted text." - }, + { url: request.urls[0], markdown: "# First\n\nExtracted text." }, { url: request.urls[1], - error: { - code: "no_content", - message: "No readable content was found." - } + error: { code: "no_content", message: "No readable content was found." } } ] }; - - globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => { - expect(input.toString()).toBe(`${apiUrl}/v1/web/extract`); - expect(init?.method).toBe("POST"); - expect(init?.headers).toMatchObject({ - Authorization: `Bearer ${accessToken}`, - "x-session-id": sessionId - }); - - const body = JSON.parse(String(init?.body)) as { encrypted: string }; - expect(JSON.parse(decryptMessage(sessionKey, body.encrypted))).toEqual(request); - - return new Response( - JSON.stringify({ encrypted: encryptMessage(sessionKey, JSON.stringify(response)) }), - { status: 200, headers: { "Content-Type": "application/json" } } - ); - }) as typeof fetch; + const fetch = spyOn(transportV2Client, "fetch").mockImplementation(async (input) => { + expectWebRequest(input, "/v1/web/extract"); + expect(JSON.parse(new TextDecoder().decode(input.body!))).toEqual(request); + return Response.json(response); + }); const result = await webExtract(request); - expect(result).toEqual(response); expect(result.pages.map((page) => page.url)).toEqual(request.urls); expect(result.pages[1].error?.code).toBe("no_content"); + expect(fetch).toHaveBeenCalledTimes(1); }); -test("web validation errors surface without an attestation retry", async () => { - let requestCount = 0; - - globalThis.fetch = mock(async (input: string | URL | Request) => { - requestCount += 1; - expect(input.toString()).toBe(`${apiUrl}/v1/web/search`); - - return new Response( - JSON.stringify({ - status: 422, - code: "invalid_request", - message: "The web request is invalid." - }), - { status: 422, headers: { "Content-Type": "application/json" } } +test("web validation errors surface without a transport retry", async () => { + const fetch = spyOn(transportV2Client, "fetch").mockImplementation(async (input) => { + expectWebRequest(input, "/v1/web/search"); + return Response.json( + { status: 422, code: "invalid_request", message: "The web request is invalid." }, + { status: 422 } ); - }) as typeof fetch; + }); - try { - await webSearch({ query: "maple privacy", limit: 51 }); - throw new Error("expected webSearch to reject invalid input"); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toBe("The web request is invalid."); - } - - expect(requestCount).toBe(1); + await expect(webSearch({ query: "maple privacy", limit: 51 })).rejects.toThrow( + "The web request is invalid." + ); + expect(fetch).toHaveBeenCalledTimes(1); }); diff --git a/sdk/src/lib/test/models.test.ts b/sdk/src/lib/test/models.test.ts index 63e7c9e15..4bdbc3a4a 100644 --- a/sdk/src/lib/test/models.test.ts +++ b/sdk/src/lib/test/models.test.ts @@ -1,132 +1,101 @@ -import { afterEach, beforeEach, expect, mock, test } from "bun:test"; -import { encryptMessage } from "../encryption"; -import { cacheAttestationSessionForTesting } from "../getAttestation"; -import type { PcrConfig } from "../pcr"; +import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; +import type { TransportV2FetchInput } from "../transportV2/client"; +import { transportV2Client } from "../transportV2/client"; +import { installTransportV2Credentials } from "../transportV2/auth"; import { fetchModelCatalog, fetchModels, getApiPcrConfig, getApiUrl, setApiUrl } from "../api"; const apiUrl = "https://models.example.com"; -const sessionId = "models-session-id"; -const sessionKey = new Uint8Array(32).fill(23); -const verifiedPcr0 = - "eeddbb58f57c38894d6d5af5e575fbe791c5bf3bbcfb5df8da8cfcf0c2e1da1913108e6a762112444740b88c163d7f4b"; -const pcrConfig: PcrConfig = { pcr0Values: [verifiedPcr0], remoteAttestation: false }; const modelsResponse = { object: "list" as const, - data: [ - { - id: "test-model", - object: "model" as const, - created: 0, - owned_by: "opensecret" - } - ] + data: [{ id: "test-model", object: "model" as const, created: 0, owned_by: "opensecret" }] }; - -const originalFetch = globalThis.fetch; const originalApiUrl = getApiUrl(); const originalApiPcrConfig = getApiPcrConfig(); -beforeEach(async () => { - window.localStorage.clear(); - window.sessionStorage.clear(); - setApiUrl(apiUrl, pcrConfig); - await cacheAttestationSessionForTesting( - apiUrl, - pcrConfig, - { sessionKey, sessionId }, - verifiedPcr0 - ); +function token(kind: "access_descriptor" | "resumption"): string { + const audience = + kind === "access_descriptor" + ? "urn:opensecret:internal:transport-v2:user:access-descriptor" + : "urn:opensecret:internal:transport-v2:user:resumption"; + const payload = Buffer.from( + JSON.stringify({ + iss: "urn:opensecret:transport-v2", + aud: audience, + tv: 2, + tk: kind, + pk: "user", + sub: "user-123", + exp: 2_000_000_000 + }) + ).toString("base64url"); + return `e30.${payload}.c2ln`; +} + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + setApiUrl(apiUrl, { environment: "development" }); }); afterEach(() => { - globalThis.fetch = originalFetch; + mock.restore(); setApiUrl(originalApiUrl, originalApiPcrConfig); - window.localStorage.clear(); - window.sessionStorage.clear(); + localStorage.clear(); + sessionStorage.clear(); }); -function encryptedModelsResponse() { - return new Response( - JSON.stringify({ - encrypted: encryptMessage(sessionKey, JSON.stringify(modelsResponse)) - }), - { status: 200, headers: { "Content-Type": "application/json" } } - ); +function successfulModels(input: TransportV2FetchInput): Promise { + expect(input.url).toBe(`${apiUrl}/v1/models`); + expect(input.method).toBe("GET"); + expect(input.body).toBeNull(); + return Promise.resolve(Response.json(modelsResponse)); } -test("fetchModels uses the encrypted session before sign-in", async () => { - globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => { - expect(input.toString()).toBe(`${apiUrl}/v1/models`); - expect(init?.method).toBe("GET"); - expect(init?.body).toBeUndefined(); - - const headers = new Headers(init?.headers); - expect(headers.get("x-session-id")).toBe(sessionId); - expect(headers.has("Authorization")).toBe(false); - - return encryptedModelsResponse(); - }) as typeof fetch; +test("fetchModels uses an anonymous v2 authority before sign-in", async () => { + const fetch = spyOn(transportV2Client, "fetch").mockImplementation(async (input) => { + expect(input.authority).toEqual({ kind: "anonymous", purpose: "public" }); + return successfulModels(input); + }); await expect(fetchModels()).resolves.toEqual(modelsResponse.data); + expect(fetch).toHaveBeenCalledTimes(1); }); -test("fetchModels preserves stored JWT authentication", async () => { - window.localStorage.setItem("access_token", "models-access-token"); - - globalThis.fetch = mock(async (_input: string | URL | Request, init?: RequestInit) => { - const headers = new Headers(init?.headers); - expect(headers.get("Authorization")).toBe("Bearer models-access-token"); - expect(headers.get("x-session-id")).toBe(sessionId); - return encryptedModelsResponse(); - }) as typeof fetch; +test("fetchModels preserves a stored user authority without an outer token", async () => { + installTransportV2Credentials(apiUrl, "user", token("access_descriptor"), token("resumption")); + const fetch = spyOn(transportV2Client, "fetch").mockImplementation(async (input) => { + expect(input.authority).toMatchObject({ kind: "user", principalId: "user-123" }); + expect(input.authority).toHaveProperty("generation"); + expect(new Headers(input.headers).has("authorization")).toBe(false); + return successfulModels(input); + }); await expect(fetchModels()).resolves.toEqual(modelsResponse.data); + expect(fetch).toHaveBeenCalledTimes(1); }); -test("fetchModels never downgrades a rejected stored JWT to anonymous access", async () => { - let requestCount = 0; - window.localStorage.setItem("access_token", "rejected-access-token"); - - globalThis.fetch = mock(async (_input: string | URL | Request, init?: RequestInit) => { - requestCount += 1; - const headers = new Headers(init?.headers); - expect(headers.get("Authorization")).toBe("Bearer rejected-access-token"); - - return Response.json({ message: "Invalid JWT" }, { status: 401 }); - }) as typeof fetch; - - await expect(fetchModels()).rejects.toThrow("No refresh token available"); - expect(requestCount).toBe(1); -}); - -test("fetchModels never downgrades a rejected API key to anonymous access", async () => { - let requestCount = 0; - window.localStorage.setItem("access_token", "stored-jwt"); - - globalThis.fetch = mock(async (_input: string | URL | Request, init?: RequestInit) => { - requestCount += 1; - const headers = new Headers(init?.headers); - expect(headers.get("Authorization")).toBe("Bearer invalid-api-key"); - +test("fetchModels binds an explicit API key and never retries anonymously", async () => { + const fetch = spyOn(transportV2Client, "fetch").mockImplementation(async (input) => { + expect(input.authority).toEqual({ kind: "api_key", value: "invalid-api-key" }); return Response.json({ message: "Invalid API key" }, { status: 401 }); - }) as typeof fetch; + }); await expect(fetchModels("invalid-api-key")).rejects.toThrow("Invalid API key"); - expect(requestCount).toBe(1); + expect(fetch).toHaveBeenCalledTimes(1); }); -test("fetchModels does not interpret an explicitly empty API key as anonymous", async () => { - const fetchMock = mock(async () => encryptedModelsResponse()); - globalThis.fetch = fetchMock as typeof fetch; - - await expect(fetchModels("")).rejects.toThrow("No access token available"); - expect(fetchMock).toHaveBeenCalledTimes(0); -}); - -test("fetchModelCatalog remains authentication-required", async () => { - const fetchMock = mock(async () => encryptedModelsResponse()); - globalThis.fetch = fetchMock as typeof fetch; - - await expect(fetchModelCatalog()).rejects.toThrow("No access token available"); - expect(fetchMock).toHaveBeenCalledTimes(0); +test("fetchModelCatalog remains user-authenticated", async () => { + const catalog = { + ...modelsResponse, + aliases: [], + defaults: { quick: "auto:quick" as const, powerful: "auto:powerful" as const } + }; + const fetch = spyOn(transportV2Client, "fetch").mockImplementation(async (input) => { + expect(input.authority).toMatchObject({ kind: "user", principalId: "user-123" }); + expect(input.authority).toHaveProperty("generation"); + return Response.json(catalog); + }); + + await expect(fetchModelCatalog()).resolves.toEqual(catalog); + expect(fetch).toHaveBeenCalledTimes(1); }); diff --git a/sdk/src/lib/test/nativeHandoffGrant.test.ts b/sdk/src/lib/test/nativeHandoffGrant.test.ts new file mode 100644 index 000000000..2e5fb8cc1 --- /dev/null +++ b/sdk/src/lib/test/nativeHandoffGrant.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, mock, test } from "bun:test"; +import { mintNativeHandoffGrantWithDependencies } from "../api"; + +const API_URL = "https://api.example.test"; +const NATIVE_SESSION_ID = "abcdef12-2222-3333-4444-555555555555"; +const NATIVE_ATTEMPT_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; +const GRANT = "header.payload.signature"; + +describe("native handoff grant minting", () => { + test("sends the exact authenticated request and returns the validated response", async () => { + const call = mock(async () => ({ grant: GRANT, expires_at: 1_800_000_000 })); + + await expect( + mintNativeHandoffGrantWithDependencies(NATIVE_SESSION_ID, NATIVE_ATTEMPT_ID, API_URL, call) + ).resolves.toEqual({ grant: GRANT, expires_at: 1_800_000_000 }); + expect(call).toHaveBeenCalledTimes(1); + expect(call).toHaveBeenCalledWith( + `${API_URL}/auth/native-handoff/grant`, + "POST", + { + native_session_id: NATIVE_SESSION_ID, + native_attempt_id: NATIVE_ATTEMPT_ID + }, + "Failed to mint native handoff grant" + ); + }); + + test("rejects non-canonical identifiers before sending", async () => { + const call = mock(async () => ({ grant: GRANT, expires_at: 1 })); + + await expect( + mintNativeHandoffGrantWithDependencies( + NATIVE_SESSION_ID.toUpperCase(), + NATIVE_ATTEMPT_ID, + API_URL, + call + ) + ).rejects.toThrow("nativeSessionId must be a non-nil canonical lowercase UUID"); + await expect( + mintNativeHandoffGrantWithDependencies(NATIVE_SESSION_ID, "not-an-attempt", API_URL, call) + ).rejects.toThrow("nativeAttemptId must be a non-nil canonical lowercase UUID"); + await expect( + mintNativeHandoffGrantWithDependencies( + "00000000-0000-0000-0000-000000000000", + NATIVE_ATTEMPT_ID, + API_URL, + call + ) + ).rejects.toThrow("nativeSessionId must be a non-nil canonical lowercase UUID"); + expect(call).not.toHaveBeenCalled(); + }); + + test("requires an exact compact JWT response shape", async () => { + const invalidResponses: unknown[] = [ + null, + { grant: GRANT }, + { grant: GRANT, expires_at: 1, extra: true }, + { grant: "", expires_at: 1 }, + { grant: "two.segments", expires_at: 1 }, + { grant: "padded=.payload.signature", expires_at: 1 }, + { grant: `a.${"b".repeat(4093)}.c`, expires_at: 1 }, + { grant: GRANT, expires_at: -1 }, + { grant: GRANT, expires_at: 1.5 }, + { grant: GRANT, expires_at: Number.MAX_SAFE_INTEGER + 1 } + ]; + + for (const response of invalidResponses) { + await expect( + mintNativeHandoffGrantWithDependencies( + NATIVE_SESSION_ID, + NATIVE_ATTEMPT_ID, + API_URL, + async () => response + ) + ).rejects.toThrow("Native handoff grant response"); + } + }); + + test("accepts the maximum grant length and a zero Unix expiry", async () => { + const grant = `${"a".repeat(1364)}.${"b".repeat(1364)}.${"c".repeat(1366)}`; + expect(grant.length).toBe(4096); + + await expect( + mintNativeHandoffGrantWithDependencies( + NATIVE_SESSION_ID, + NATIVE_ATTEMPT_ID, + API_URL, + async () => ({ grant, expires_at: 0 }) + ) + ).resolves.toEqual({ grant, expires_at: 0 }); + }); +}); diff --git a/sdk/src/lib/test/transportV2.test.ts b/sdk/src/lib/test/transportV2.test.ts index 10f96c405..e182f6618 100644 --- a/sdk/src/lib/test/transportV2.test.ts +++ b/sdk/src/lib/test/transportV2.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import vectors from "../../../testdata/transport-v2-golden-vectors.json"; import { TransportV2ProtocolError, + TRANSPORT_V2_LIMITS, decodeCanonicalBase64, decryptTransportV2Handshake, decryptTransportV2Record, @@ -235,6 +236,44 @@ describe("transport v2 cross-language vectors", () => { }); describe("transport v2 dormant session engine", () => { + test("accepts the exact 50 MiB logical request boundary and rejects one byte more", () => { + const body = new Uint8Array(TRANSPORT_V2_LIMITS.requestLogicalBodyBytes); + const envelope = serializeRequestEnvelope({ + requestId: vectors.request_id_hex, + responseMode: "unary", + credential: null, + cacheNamespaceRoot: null, + request: { + method: "POST", + path: "/v1/responses", + query: null, + headers: [], + body + } + }); + expect(envelope.length).toBeLessThanOrEqual(TRANSPORT_V2_LIMITS.requestEnvelopeBytes); + body.fill(0); + envelope.fill(0); + + const oversized = new Uint8Array(TRANSPORT_V2_LIMITS.requestLogicalBodyBytes + 1); + expect(() => + serializeRequestEnvelope({ + requestId: vectors.request_id_hex, + responseMode: "unary", + credential: null, + cacheNamespaceRoot: null, + request: { + method: "POST", + path: "/v1/responses", + query: null, + headers: [], + body: oversized + } + }) + ).toThrow("body exceeds its size limit"); + oversized.fill(0); + }); + test("rejects an empty attestation nonce before generating key material", () => { expect(() => new TransportV2Handshake("")).toThrow("invalid length"); @@ -278,10 +317,10 @@ describe("transport v2 dormant session engine", () => { const outbound = prepared.takeHttpRequest(); expect(outbound.path).toBe("/v2/request"); expect(outbound.headers).toEqual({ - "content-type": "application/json", + "content-type": "application/octet-stream", "x-session-id": vectors.session_id }); - expect(outbound.body).not.toContain("/v1/models"); + expect(new TextDecoder().decode(outbound.body)).not.toContain("/v1/models"); expect(() => prepared.takeHttpRequest()).toThrow("already been taken"); expect(() => prepared.createStreamDecoder()).toThrow("did not select streaming"); @@ -301,12 +340,10 @@ describe("transport v2 dormant session engine", () => { ); session.dispose(); expect(session.isDisposed).toBe(true); - const response = prepared.decryptUnaryResponse( - JSON.stringify({ encrypted: encodeCanonicalBase64(responseRecord) }) - ); + const response = prepared.decryptUnaryResponse(responseRecord); expect(response.status).toBe(200); expect(JSON.parse(new TextDecoder().decode(response.body!))).toEqual({ ok: true }); - expect(() => prepared.decryptUnaryResponse("{}")).toThrow("already selected"); + expect(() => prepared.decryptUnaryResponse(new Uint8Array(0))).toThrow("already selected"); }); test("decodes arbitrary carrier splits and requires ordered authenticated finality", async () => { @@ -336,7 +373,9 @@ describe("transport v2 dormant session engine", () => { vectors.expires_at_unix_seconds - 1 ); prepared.takeHttpRequest(); - expect(() => prepared.decryptUnaryResponse("{}")).toThrow("did not select a unary"); + expect(() => prepared.decryptUnaryResponse(new Uint8Array(0))).toThrow( + "did not select a unary" + ); const decoder = prepared.createStreamDecoder(); session.dispose(); @@ -443,9 +482,7 @@ describe("transport v2 dormant session engine", () => { expect(() => session.prepareRequest(unaryInput, afterErrorRandom, vectors.expires_at_unix_seconds - 1) ).toThrow("response record budget"); - const response = prepared.decryptPreStartUnaryError( - JSON.stringify({ encrypted: encodeCanonicalBase64(encrypted) }) - ); + const response = prepared.decryptPreStartUnaryError(encrypted); expect(response.status).toBe(409); expect(() => prepared.createStreamDecoder()).toThrow("already selected"); const afterError = session.prepareRequest( @@ -646,7 +683,7 @@ describe("transport v2 dormant session engine", () => { ); prepared.dispose(); expect(() => prepared.takeHttpRequest()).toThrow("already been taken"); - expect(() => prepared.decryptUnaryResponse("{}")).toThrow("already selected"); + expect(() => prepared.decryptUnaryResponse(new Uint8Array(0))).toThrow("already selected"); let releases = 0; const decoder = new TransportV2StreamDecoder( diff --git a/sdk/src/lib/test/transportV2Auth.test.ts b/sdk/src/lib/test/transportV2Auth.test.ts new file mode 100644 index 000000000..7432f7d76 --- /dev/null +++ b/sdk/src/lib/test/transportV2Auth.test.ts @@ -0,0 +1,358 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + canonicalizeTransportV2ApiUrl, + clearLegacyTransportV1Credentials, + clearTransportV2CacheRoot, + clearTransportV2Credentials, + clearTransportV2CredentialsIfCurrent, + commitTransportV2AuthBundleImport, + exportTransportV2AuthBundle, + getOrCreateTransportV2CacheRoot, + importTransportV2AuthBundle, + installTransportV2Credentials, + prepareTransportV2AuthBundleImport, + readTransportV2Credentials, + setTransportV2CacheRoot, + snapshotTransportV2Auth, + subscribeTransportV2AuthInvalidation +} from "../transportV2/auth"; + +const API_URL = "https://auth.example.test/service"; +const OTHER_API_URL = "https://other.example.test/service"; +const CACHE_ROOT_PREFIX = "opensecret:transport-v2:cache-root:v1:"; +const TOKEN_ISSUER = "urn:opensecret:transport-v2"; +const USER_ACCESS_AUDIENCE = "urn:opensecret:internal:transport-v2:user:access-descriptor"; +const USER_RESUMPTION_AUDIENCE = "urn:opensecret:internal:transport-v2:user:resumption"; + +function storageMock(): Storage { + const values = new Map(); + return { + setItem(key: string, value: string) { + values.set(key, value); + }, + getItem(key: string) { + return values.get(key) ?? null; + }, + removeItem(key: string) { + values.delete(key); + }, + clear() { + values.clear(); + }, + get length() { + return values.size; + }, + key(index: number) { + return Array.from(values.keys())[index] ?? null; + } + } as Storage; +} + +if (!globalThis.localStorage) { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storageMock() + }); +} + +function unpaddedBase64Url(value: string | Uint8Array): string { + return Buffer.from(value).toString("base64url"); +} + +function credential( + tokenKind: "access_descriptor" | "resumption", + principalId = "user-123", + expiresAtUnixSeconds = 2_000_000_000 +): string { + const audience = + tokenKind === "access_descriptor" ? USER_ACCESS_AUDIENCE : USER_RESUMPTION_AUDIENCE; + const header = unpaddedBase64Url(JSON.stringify({ alg: "EdDSA", typ: "JWT" })); + const claims = unpaddedBase64Url( + JSON.stringify({ + iss: TOKEN_ISSUER, + aud: audience, + tv: 2, + tk: tokenKind, + pk: "user", + sub: principalId, + exp: expiresAtUnixSeconds + }) + ); + const signature = unpaddedBase64Url(new Uint8Array(64).fill(0x5a)); + return `${header}.${claims}.${signature}`; +} + +function installUser(apiUrl = API_URL) { + const accessToken = credential("access_descriptor"); + const refreshToken = credential("resumption", "user-123", 2_000_003_600); + const credentials = installTransportV2Credentials(apiUrl, "user", accessToken, refreshToken); + return { accessToken, refreshToken, credentials }; +} + +function decodeBundle(bundle: string): Record { + return JSON.parse(Buffer.from(bundle, "base64url").toString("utf8")) as Record; +} + +function encodeBundle(bundle: Record): string { + return unpaddedBase64Url(JSON.stringify(bundle)); +} + +function storageKeys(): string[] { + return Array.from({ length: globalThis.localStorage.length }, (_, index) => + globalThis.localStorage.key(index) + ) + .filter((key): key is string => key !== null) + .sort(); +} + +function fixedRandom(value: Uint8Array) { + let calls = 0; + const random = { + getRandomValues(array: T): T { + calls += 1; + if (!(array instanceof Uint8Array) || array.length !== value.length) { + throw new Error("unexpected random request"); + } + array.set(value); + return array; + } + } as Crypto; + return { random, calls: () => calls }; +} + +function cleanup(): void { + for (const apiUrl of [API_URL, OTHER_API_URL, "https://api.example.test/base"]) { + clearTransportV2Credentials(apiUrl); + clearTransportV2CacheRoot(apiUrl); + } + clearLegacyTransportV1Credentials(); + globalThis.localStorage.clear(); +} + +beforeEach(cleanup); +afterEach(cleanup); + +describe("transport v2 auth storage and transfer bundle", () => { + test("canonicalizes one API scope before storing or reading credentials", () => { + const configuredUrl = "HTTPS://API.EXAMPLE.TEST:443/base///"; + const canonicalUrl = "https://api.example.test/base"; + + expect(canonicalizeTransportV2ApiUrl(configuredUrl)).toBe(canonicalUrl); + expect(canonicalizeTransportV2ApiUrl("http://LOCALHOST:80/")).toBe("http://localhost"); + expect(() => canonicalizeTransportV2ApiUrl("http://api.example.test")).toThrow( + "requires HTTPS" + ); + expect(() => canonicalizeTransportV2ApiUrl("https://api.example.test/?query=1")).toThrow( + "must not contain credentials, a query, or a fragment" + ); + + const installed = installUser(configuredUrl); + expect(installed.credentials.apiOrigin).toBe(canonicalUrl); + expect(readTransportV2Credentials(`${canonicalUrl}/`, "user")).toEqual(installed.credentials); + }); + + test("exports and imports an unpadded base64url bundle with exactly five fields", async () => { + const { credentials } = installUser(API_URL); + setTransportV2CacheRoot( + API_URL, + Uint8Array.from({ length: 32 }, (_, index) => index) + ); + + const exported = await exportTransportV2AuthBundle(`${API_URL}/`); + expect(exported).toMatch(/^[A-Za-z0-9_-]+$/); + expect(exported).not.toContain("="); + + const decoded = decodeBundle(exported); + expect(Object.keys(decoded).sort()).toEqual( + [ + "version", + "api_origin", + "access_token", + "refresh_token", + "cache_namespace_root_base64" + ].sort() + ); + expect(decoded.version).toBe(2); + expect(decoded.api_origin).toBe(API_URL); + + await expect(importTransportV2AuthBundle(`${exported}=`, API_URL)).rejects.toThrow( + "not canonical base64url" + ); + await expect( + importTransportV2AuthBundle(encodeBundle({ ...decoded, extra: true }), API_URL) + ).rejects.toThrow("unexpected shape"); + const { refresh_token: _removed, ...missingField } = decoded; + await expect(importTransportV2AuthBundle(encodeBundle(missingField), API_URL)).rejects.toThrow( + "unexpected shape" + ); + + clearTransportV2Credentials(API_URL); + clearTransportV2CacheRoot(API_URL); + await importTransportV2AuthBundle(exported, API_URL); + const imported = readTransportV2Credentials(API_URL, "user"); + expect(imported).toMatchObject({ + kind: credentials.kind, + principalId: credentials.principalId, + apiOrigin: credentials.apiOrigin, + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + accessExpiresAtUnixSeconds: credentials.accessExpiresAtUnixSeconds + }); + expect(imported!.generation).toBeGreaterThan(credentials.generation); + }); + + test("uses an exact 32-byte padded standard-base64 cache root", async () => { + installUser(API_URL); + const root = new Uint8Array(32).fill(0xff); + const expectedBase64 = Buffer.from(root).toString("base64"); + setTransportV2CacheRoot(API_URL, root); + + const decoded = decodeBundle(await exportTransportV2AuthBundle(API_URL)); + expect(decoded.cache_namespace_root_base64).toBe(expectedBase64); + expect(expectedBase64).toMatch(/^[A-Za-z0-9+/]+=$/); + expect(Buffer.from(expectedBase64, "base64")).toHaveLength(32); + + const unpadded = { + ...decoded, + cache_namespace_root_base64: expectedBase64.replace(/=+$/u, "") + }; + await expect(importTransportV2AuthBundle(encodeBundle(unpadded), API_URL)).rejects.toThrow( + "cache root is invalid" + ); + + const urlSafe = { + ...decoded, + cache_namespace_root_base64: expectedBase64.replaceAll("/", "_") + }; + await expect(importTransportV2AuthBundle(encodeBundle(urlSafe), API_URL)).rejects.toThrow(); + + const shortRoot = { + ...decoded, + cache_namespace_root_base64: Buffer.from(new Uint8Array(31)).toString("base64") + }; + await expect(importTransportV2AuthBundle(encodeBundle(shortRoot), API_URL)).rejects.toThrow( + "cache root is invalid" + ); + }); + + test("rejects a bundle bound to another canonical API origin without side effects", async () => { + installUser(API_URL); + setTransportV2CacheRoot(API_URL, new Uint8Array(32).fill(0x33)); + const exported = await exportTransportV2AuthBundle(API_URL); + const keysBefore = storageKeys(); + + await expect(importTransportV2AuthBundle(exported, OTHER_API_URL)).rejects.toThrow( + "belongs to a different API origin" + ); + + expect(readTransportV2Credentials(OTHER_API_URL, "user")).toBeNull(); + expect(storageKeys()).toEqual(keysBefore); + }); + + test("ignores legacy compatibility credentials and clears them without clearing v2 state", () => { + globalThis.localStorage.setItem("access_token", credential("access_descriptor", "legacy-user")); + globalThis.localStorage.setItem("refresh_token", credential("resumption", "legacy-user")); + + expect(readTransportV2Credentials(API_URL, "user")).toBeNull(); + clearLegacyTransportV1Credentials(); + expect(globalThis.localStorage.getItem("access_token")).toBeNull(); + expect(globalThis.localStorage.getItem("refresh_token")).toBeNull(); + + const installed = installUser(API_URL); + clearLegacyTransportV1Credentials(); + expect(globalThis.localStorage.getItem("access_token")).toBeNull(); + expect(globalThis.localStorage.getItem("refresh_token")).toBeNull(); + expect(readTransportV2Credentials(API_URL, "user")).toEqual(installed.credentials); + }); + + test("creates one stable cache root for canonically equivalent API URLs", () => { + const expected = Uint8Array.from({ length: 32 }, (_, index) => 0xa0 + index); + const source = fixedRandom(expected); + const first = getOrCreateTransportV2CacheRoot(`${API_URL}///`, source.random); + expect(first).toEqual(expected); + expect(source.calls()).toBe(1); + + first.fill(0); + const neverRandom = { + getRandomValues(): never { + throw new Error("stored cache root should be reused"); + } + } as unknown as Crypto; + const second = getOrCreateTransportV2CacheRoot(API_URL, neverRandom); + expect(second).toEqual(expected); + + const cacheRootKey = storageKeys().find((key) => key.startsWith(CACHE_ROOT_PREFIX)); + expect(cacheRootKey).toBeDefined(); + expect(globalThis.localStorage.getItem(cacheRootKey!)).toBe( + Buffer.from(expected).toString("base64") + ); + }); + + test("stale installs and clears cannot overwrite or remove a newer principal", () => { + installUser(API_URL); + const stale = snapshotTransportV2Auth(API_URL, "user"); + const newer = installTransportV2Credentials( + API_URL, + "user", + credential("access_descriptor", "user-456"), + credential("resumption", "user-456", 2_000_003_600) + ); + + expect(() => + installTransportV2Credentials( + API_URL, + "user", + credential("access_descriptor", "user-123"), + credential("resumption", "user-123", 2_000_003_600), + stale + ) + ).toThrow("authentication state changed"); + expect(clearTransportV2CredentialsIfCurrent(stale)).toBe(false); + expect(readTransportV2Credentials(API_URL, "user")).toEqual(newer); + }); + + test("notifies React auth for principal replacement and exact current invalidation", () => { + installUser(API_URL); + const current = snapshotTransportV2Auth(API_URL, "user"); + let invalidations = 0; + const unsubscribe = subscribeTransportV2AuthInvalidation(API_URL, "user", () => { + invalidations += 1; + }); + try { + const newer = installTransportV2Credentials( + API_URL, + "user", + credential("access_descriptor", "user-456"), + credential("resumption", "user-456", 2_000_003_600) + ); + expect(clearTransportV2CredentialsIfCurrent(current)).toBe(false); + expect(invalidations).toBe(1); + expect(clearTransportV2CredentialsIfCurrent(snapshotTransportV2Auth(API_URL, "user"))).toBe( + true + ); + expect(invalidations).toBe(2); + expect(newer.principalId).toBe("user-456"); + } finally { + unsubscribe(); + } + }); + + test("a prepared bundle import cannot replace credentials installed after its snapshot", async () => { + installUser(API_URL); + setTransportV2CacheRoot(API_URL, new Uint8Array(32).fill(0x44)); + const bundle = await exportTransportV2AuthBundle(API_URL); + const expected = snapshotTransportV2Auth(API_URL, "user"); + const prepared = prepareTransportV2AuthBundleImport(bundle, API_URL); + const newer = installTransportV2Credentials( + API_URL, + "user", + credential("access_descriptor", "user-456"), + credential("resumption", "user-456", 2_000_003_600) + ); + + expect(() => commitTransportV2AuthBundleImport(prepared, expected)).toThrow( + "authentication state changed" + ); + expect(readTransportV2Credentials(API_URL, "user")).toEqual(newer); + }); +}); diff --git a/sdk/src/lib/test/transportV2Client.test.ts b/sdk/src/lib/test/transportV2Client.test.ts new file mode 100644 index 000000000..18805d4e6 --- /dev/null +++ b/sdk/src/lib/test/transportV2Client.test.ts @@ -0,0 +1,1170 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { decode, encode } from "@stablelib/base64"; +import nacl from "tweetnacl"; +import type { AttestationDocument } from "../attestation"; +import { + clearTransportV2CacheRoot, + clearTransportV2Credentials, + installTransportV2Credentials, + readTransportV2Credentials, + snapshotTransportV2Auth, + subscribeTransportV2AuthInvalidation +} from "../transportV2/auth"; +import { + TransportV2Client, + type TransportV2ClientDependencies, + type TransportV2FetchInput +} from "../transportV2/client"; +import { + TRANSPORT_V2_LIMITS, + decryptTransportV2Record, + deriveTransportV2DirectionalKeys, + encodeCanonicalBase64, + encryptTransportV2Record, + requestRecordAad, + streamResponseRecordAad, + unaryResponseRecordAad +} from "../transportV2"; +import { encodeUtf8, uuidToBytes } from "../transportV2/encoding"; + +const API_URL = "http://127.0.0.1:3010/base"; +const USER_ID = "00112233-4455-6677-8899-aabbccddeeff"; +const HANDSHAKE_INFO = encodeUtf8("opensecret/transport-v2/handshake-key"); +const HANDSHAKE_AAD = encodeUtf8("opensecret/transport-v2/key-exchange"); + +type WireRequest = { + request_id: string; + response_mode: "unary" | "stream"; + credential: { kind: "api_key" | "resumption"; value_base64: string } | null; + cache_namespace_root_base64: string | null; + request: { + method: string; + path: string; + query: string | null; + headers: Array<{ name: string; value_base64: string }>; + body_base64: string | null; + }; +}; + +type ServerSession = { + requestKey: Uint8Array; + responseKey: Uint8Array; + expiresAt: number; + authority: "anonymous" | "user" | "api_key"; +}; + +function writeU64(value: number): Uint8Array { + const bytes = new Uint8Array(8); + new DataView(bytes.buffer).setBigUint64(0, BigInt(value), false); + return bytes; +} + +async function handshakeKey(sharedSecret: Uint8Array): Promise { + const material = await crypto.subtle.importKey("raw", sharedSecret, "HKDF", false, [ + "deriveBits" + ]); + return new Uint8Array( + await crypto.subtle.deriveBits( + { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: HANDSHAKE_INFO }, + material, + 256 + ) + ); +} + +function token(kind: "access_descriptor" | "resumption", principal = USER_ID): string { + const audience = + kind === "access_descriptor" + ? "urn:opensecret:internal:transport-v2:user:access-descriptor" + : "urn:opensecret:internal:transport-v2:user:resumption"; + const payload = Buffer.from( + JSON.stringify({ + iss: "urn:opensecret:transport-v2", + aud: audience, + tv: 2, + tk: kind, + pk: "user", + sub: principal, + exp: Math.floor(Date.now() / 1000) + 3600 + }) + ).toString("base64url"); + return `e30.${payload}.c2ln`; +} + +class V2TestServer { + readonly keyPair = nacl.box.keyPair(); + readonly sessions = new Map(); + readonly requests: WireRequest[] = []; + readonly outerUrls: string[] = []; + attestationCount = 0; + keyExchangeCount = 0; + requestCount = 0; + failKeyExchanges = 0; + failAfterPath: string | null = null; + unauthorizedOncePath: string | null = null; + sessionExhaustedOncePath: string | null = null; + preStartSessionExhaustedOncePath: string | null = null; + streamSessionExhaustedOncePath: string | null = null; + genericServiceUnavailableOncePath: string | null = null; + appendStreamRecordAfterEnd = false; + streamTrailingGate: Promise | null = null; + pauseAfterPath: string | null = null; + pauseReached: (() => void) | null = null; + pauseRelease: Promise | null = null; + sessionLifetimeSeconds = 3600; + #nextSession = 1; + + dependencies(sessionResponseRecordLimit?: number): TransportV2ClientDependencies { + return { + fetch: this.fetch, + crypto: globalThis.crypto, + verifyAttestationDocument: async () => + ({ + module_id: "test-enclave", + digest: "SHA384", + timestamp: Date.now(), + pcrs: new Map(), + certificate: new Uint8Array(1), + cabundle: [], + public_key: new Uint8Array(this.keyPair.publicKey), + user_data: null, + nonce: null + }) as AttestationDocument, + validatePcr0Hash: async () => { + throw new Error("loopback tests must not call remote PCR validation"); + }, + sessionResponseRecordLimit + }; + } + + fetch = async (input: string | URL | Request, init?: RequestInit): Promise => { + const url = input.toString(); + this.outerUrls.push(url); + const headers = new Headers(init?.headers); + expect(headers.has("authorization")).toBe(false); + if (url.includes("/v2/attestation/")) { + this.attestationCount += 1; + expect(init?.method).toBe("GET"); + return Response.json({ attestation_document: "test-document" }); + } + if (url.endsWith("/v2/key_exchange")) { + this.keyExchangeCount += 1; + if (this.failKeyExchanges > 0) { + this.failKeyExchanges -= 1; + throw new Error("key exchange unavailable"); + } + expect(headers.get("content-type")).toBe("application/json"); + return this.#keyExchange(String(init?.body)); + } + if (url.endsWith("/v2/request")) { + this.requestCount += 1; + expect(headers.get("content-type")).toBe("application/octet-stream"); + expect(init?.body).toBeInstanceOf(Uint8Array); + const body = new Uint8Array(init?.body as Uint8Array); + return this.#request(headers.get("x-session-id") ?? "", body); + } + throw new Error(`unexpected outer URL: ${url}`); + }; + + async #keyExchange(body: string): Promise { + const request = JSON.parse(body) as { client_public_key: string }; + const clientPublicKey = decode(request.client_public_key); + const sharedSecret = nacl.scalarMult(this.keyPair.secretKey, clientPublicKey); + const key = await handshakeKey(sharedSecret); + const sessionId = `00000000-0000-4000-8000-${String(this.#nextSession).padStart(12, "0")}`; + this.#nextSession += 1; + const expiresAt = Math.floor(Date.now() / 1000) + this.sessionLifetimeSeconds; + const master = new Uint8Array(32).fill(this.#nextSession & 0xff); + const payload = new Uint8Array(57); + payload[0] = 2; + payload.set(uuidToBytes(sessionId), 1); + payload.set(master, 17); + payload.set(writeU64(expiresAt), 49); + const encrypted = encryptTransportV2Record(key, payload, HANDSHAKE_AAD); + const directional = await deriveTransportV2DirectionalKeys(master); + this.sessions.set(sessionId, { + ...directional, + expiresAt, + authority: "anonymous" + }); + clientPublicKey.fill(0); + sharedSecret.fill(0); + key.fill(0); + master.fill(0); + payload.fill(0); + return Response.json({ + session_id: sessionId, + encrypted_session_key: encode(encrypted) + }); + } + + async #request(sessionId: string, encrypted: Uint8Array): Promise { + const session = this.sessions.get(sessionId); + if (!session) throw new Error("unknown test session"); + const plaintext = decryptTransportV2Record( + session.requestKey, + encrypted, + requestRecordAad(sessionId), + TRANSPORT_V2_LIMITS.requestEnvelopeBytes + ); + const request = JSON.parse(new TextDecoder().decode(plaintext)) as WireRequest; + plaintext.fill(0); + encrypted.fill(0); + this.requests.push(request); + + const path = request.request.path; + let status = 200; + let body: unknown = { ok: true, path }; + if (path === "/login") { + expect(session.authority).toBe("anonymous"); + expect(request.cache_namespace_root_base64).not.toBeNull(); + session.authority = "user"; + body = { + id: USER_ID, + access_token: token("access_descriptor"), + refresh_token: token("resumption") + }; + } else if (path === "/refresh") { + expect(session.authority).toBe("anonymous"); + expect(request.credential?.kind).toBe("resumption"); + expect(request.cache_namespace_root_base64).not.toBeNull(); + session.authority = "user"; + body = { + access_token: token("access_descriptor"), + refresh_token: token("resumption") + }; + } else if (path === "/v1/models" && request.credential?.kind === "api_key") { + expect(request.cache_namespace_root_base64).not.toBeNull(); + session.authority = "api_key"; + body = { object: "list", data: [] }; + } else if (path === "/v1/models") { + body = { object: "list", data: [] }; + } else if (path.startsWith("/auth/") && !path.endsWith("/callback")) { + const provider = path.split("/")[2]; + body = { auth_url: `https://oauth.example/${provider}`, state: `${provider}-state` }; + } else if (path.endsWith("/callback")) { + const provider = path.split("/")[2]; + const callback = JSON.parse( + new TextDecoder().decode(decode(request.request.body_base64 ?? "")) + ) as { state: string }; + expect(callback.state).toBe(`${provider}-state`); + expect(session.authority).toBe("anonymous"); + session.authority = "user"; + body = { + id: USER_ID, + access_token: token("access_descriptor"), + refresh_token: token("resumption") + }; + } else if (path === "/protected/user") { + expect(session.authority).toBe("user"); + body = { user: { id: USER_ID } }; + } else if (path === "/protected/change_password") { + expect(session.authority).toBe("user"); + body = { + message: "Password changed", + access_token: token("access_descriptor"), + refresh_token: token("resumption") + }; + } else if (path === "/logout") { + expect(session.authority).toBe("user"); + body = null; + } else if (path === "/v1/responses") { + expect(session.authority === "user" || session.authority === "api_key").toBe(true); + if (request.response_mode === "stream") { + if (this.preStartSessionExhaustedOncePath === path) { + this.preStartSessionExhaustedOncePath = null; + return this.#unaryResponse(sessionId, session, request.request_id, 503, { + error: { + code: "session_exhausted", + message: "Session response capacity is exhausted" + } + }); + } + if (this.streamSessionExhaustedOncePath === path) { + this.streamSessionExhaustedOncePath = null; + return this.#streamErrorResponse(sessionId, session, request.request_id, 503, { + error: { + code: "session_exhausted", + message: "Session response capacity is exhausted" + } + }); + } + return this.#streamResponse(sessionId, session, request.request_id); + } + } else { + status = 404; + body = { message: "unsupported" }; + } + + if (this.unauthorizedOncePath === path) { + this.unauthorizedOncePath = null; + status = 401; + body = { message: "authority is no longer valid" }; + } + if (this.sessionExhaustedOncePath === path) { + this.sessionExhaustedOncePath = null; + status = 503; + body = { + error: { + code: "session_exhausted", + message: "Session request capacity is exhausted" + } + }; + } + if (this.genericServiceUnavailableOncePath === path) { + this.genericServiceUnavailableOncePath = null; + status = 503; + body = { error: { code: "provider_unavailable", message: "Try again later" } }; + } + + const response = this.#unaryResponse(sessionId, session, request.request_id, status, body); + if (this.pauseAfterPath === path) { + this.pauseReached?.(); + await this.pauseRelease; + } + if (this.failAfterPath === path) { + this.failAfterPath = null; + if (path === "/logout") this.sessions.delete(sessionId); + throw new Error(`ambiguous ${path} failure`); + } + if (path === "/logout") this.sessions.delete(sessionId); + if (status === 401 && session.authority !== "anonymous") this.sessions.delete(sessionId); + return response; + } + + #unaryResponse( + sessionId: string, + session: ServerSession, + requestId: string, + status: number, + body: unknown + ): Response { + const plaintext = encodeUtf8( + JSON.stringify({ + version: 2, + request_id: requestId, + status, + headers: [{ name: "content-type", value_base64: encode(encodeUtf8("application/json")) }], + body_base64: body === undefined ? null : encode(encodeUtf8(JSON.stringify(body))) + }) + ); + const record = encryptTransportV2Record( + session.responseKey, + plaintext, + unaryResponseRecordAad(sessionId, requestId) + ); + plaintext.fill(0); + return new Response(record, { + status: 200, + headers: { "content-type": "application/octet-stream" } + }); + } + + #streamResponse(sessionId: string, session: ServerSession, requestId: string): Response { + const records = [ + { + version: 2, + request_id: requestId, + sequence: 0, + kind: "start", + status: 200, + headers: [{ name: "content-type", value_base64: encode(encodeUtf8("text/event-stream")) }] + }, + { + version: 2, + request_id: requestId, + sequence: 1, + kind: "chunk", + body_base64: encode(encodeUtf8("event: response.completed\ndata: {}\n\n")) + }, + { version: 2, request_id: requestId, sequence: 2, kind: "end" } + ]; + const frames = records.map((value, sequence) => { + const plaintext = encodeUtf8(JSON.stringify(value)); + const encrypted = encryptTransportV2Record( + session.responseKey, + plaintext, + streamResponseRecordAad(sessionId, requestId, sequence) + ); + plaintext.fill(0); + return `data: ${encodeCanonicalBase64(encrypted)}\n\n`; + }); + const carrier = frames.join(""); + if (this.appendStreamRecordAfterEnd) { + const plaintext = encodeUtf8( + JSON.stringify({ + version: 2, + request_id: requestId, + sequence: 3, + kind: "chunk", + body_base64: encode(encodeUtf8("trailing")) + }) + ); + const encrypted = encryptTransportV2Record( + session.responseKey, + plaintext, + streamResponseRecordAad(sessionId, requestId, 3) + ); + plaintext.fill(0); + const trailing = `data: ${encodeCanonicalBase64(encrypted)}\n\n`; + const trailingGate = this.streamTrailingGate; + let emittedTrailing = false; + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encodeUtf8(carrier)); + }, + async pull(controller) { + if (!emittedTrailing) { + emittedTrailing = true; + await trailingGate; + controller.enqueue(encodeUtf8(trailing)); + return; + } + controller.close(); + } + }), + { headers: { "content-type": "text/event-stream" } } + ); + } + return new Response(carrier, { headers: { "content-type": "text/event-stream" } }); + } + + #streamErrorResponse( + sessionId: string, + session: ServerSession, + requestId: string, + status: number, + body: unknown + ): Response { + const records = [ + { + version: 2, + request_id: requestId, + sequence: 0, + kind: "start", + status: 200, + headers: [{ name: "content-type", value_base64: encode(encodeUtf8("text/event-stream")) }] + }, + { + version: 2, + request_id: requestId, + sequence: 1, + kind: "error", + status, + body_base64: encode(encodeUtf8(JSON.stringify(body))) + } + ]; + const carrier = records + .map((value, sequence) => { + const plaintext = encodeUtf8(JSON.stringify(value)); + const encrypted = encryptTransportV2Record( + session.responseKey, + plaintext, + streamResponseRecordAad(sessionId, requestId, sequence) + ); + plaintext.fill(0); + return `data: ${encodeCanonicalBase64(encrypted)}\n\n`; + }) + .join(""); + return new Response(carrier, { headers: { "content-type": "text/event-stream" } }); + } +} + +function operation( + path: string, + authority: TransportV2FetchInput["authority"], + body: unknown = undefined, + responseMode: "unary" | "stream" = "unary" +): TransportV2FetchInput { + return { + apiUrl: API_URL, + pcrConfig: { environment: "development" }, + url: `${API_URL}${path}`, + method: "POST", + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? null : encodeUtf8(JSON.stringify(body)), + responseMode, + authority + }; +} + +function userAuthority(): Extract { + const credentials = readTransportV2Credentials(API_URL, "user"); + if (!credentials) throw new Error("test user credentials are not installed"); + return { + kind: "user", + principalId: credentials.principalId, + generation: credentials.generation + }; +} + +function installUserCredentials(principal: string) { + return installTransportV2Credentials( + API_URL, + "user", + token("access_descriptor", principal), + token("resumption", principal) + ); +} + +function pausePath(server: V2TestServer, path: string) { + let markReached!: () => void; + let release!: () => void; + const reached = new Promise((resolve) => { + markReached = resolve; + }); + server.pauseAfterPath = path; + server.pauseReached = markReached; + server.pauseRelease = new Promise((resolve) => { + release = resolve; + }); + return { reached, release }; +} + +afterEach(() => { + clearTransportV2Credentials(API_URL); + clearTransportV2CacheRoot(API_URL); + localStorage.clear(); + sessionStorage.clear(); +}); + +describe("Transport V2 authority/session manager", () => { + test("singleflights one anonymous handshake and never uses a v1 transport URL", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + const [first, second] = await Promise.all([ + client.fetch(operation("/v1/models", { kind: "anonymous", purpose: "public" })), + client.fetch(operation("/v1/models", { kind: "anonymous", purpose: "public" })) + ]); + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + expect(server.attestationCount).toBe(1); + expect(server.keyExchangeCount).toBe(1); + expect(server.outerUrls.every((url) => url.includes("/v2/"))).toBe(true); + }); + + test("binds login and serves steady user requests on that exact session", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + const response = await client.fetch({ + ...operation("/protected/user", userAuthority()), + method: "GET" + }); + expect(response.ok).toBe(true); + expect(server.keyExchangeCount).toBe(1); + expect(server.requests[0].credential).toBeNull(); + expect(server.requests[0].cache_namespace_root_base64).not.toBeNull(); + expect(server.requests[1].credential).toBeNull(); + expect(server.requests[1].cache_namespace_root_base64).toBeNull(); + }); + + test("never sends a body captured under an older user generation", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + const stale = userAuthority(); + installUserCredentials("11112233-4455-6677-8899-aabbccddeeff"); + const sent = server.requestCount; + + await expect( + client.fetch({ ...operation("/protected/user", stale), method: "GET" }) + ).rejects.toThrow("authentication state changed"); + expect(server.requestCount).toBe(sent); + }); + + test("disposes an ambiguously bound login session before another attempt", async () => { + const server = new V2TestServer(); + server.failAfterPath = "/login"; + const client = new TransportV2Client(server.dependencies()); + await expect( + client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ) + ).rejects.toThrow("ambiguous /login failure"); + await expect( + client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ) + ).resolves.toBeInstanceOf(Response); + expect(server.keyExchangeCount).toBe(2); + }); + + test("a delayed login cannot overwrite credentials installed while it was in flight", async () => { + const server = new V2TestServer(); + const paused = pausePath(server, "/login"); + const client = new TransportV2Client(server.dependencies()); + const login = client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + await paused.reached; + const newer = installUserCredentials("11112233-4455-6677-8899-aabbccddeeff"); + paused.release(); + + await expect(login).rejects.toThrow("authentication state changed"); + expect(readTransportV2Credentials(API_URL, "user")).toEqual(newer); + }); + + test("a delayed successful refresh cannot overwrite a newer principal", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + client.retireAuthenticationState(API_URL, "user"); + const paused = pausePath(server, "/refresh"); + const pending = client.fetch({ + ...operation("/protected/user", userAuthority()), + method: "GET" + }); + await paused.reached; + const newer = installUserCredentials("11112233-4455-6677-8899-aabbccddeeff"); + paused.release(); + + await expect(pending).rejects.toThrow("authentication state changed"); + expect(readTransportV2Credentials(API_URL, "user")).toEqual(newer); + }); + + test("a delayed rejected refresh cannot clear a newer principal", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + client.retireAuthenticationState(API_URL, "user"); + server.unauthorizedOncePath = "/refresh"; + const paused = pausePath(server, "/refresh"); + const pending = client.fetch({ + ...operation("/protected/user", userAuthority()), + method: "GET" + }); + await paused.reached; + const newer = installUserCredentials("11112233-4455-6677-8899-aabbccddeeff"); + paused.release(); + + await expect(pending).rejects.toThrow("authentication state changed"); + expect(readTransportV2Credentials(API_URL, "user")).toEqual(newer); + }); + + test("a current rejected refresh clears credentials and emits React invalidation", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + client.retireAuthenticationState(API_URL, "user"); + server.unauthorizedOncePath = "/refresh"; + let invalidations = 0; + const unsubscribe = subscribeTransportV2AuthInvalidation(API_URL, "user", () => { + invalidations += 1; + }); + try { + await expect( + client.fetch({ ...operation("/protected/user", userAuthority()), method: "GET" }) + ).rejects.toThrow("resumption was rejected"); + expect(readTransportV2Credentials(API_URL, "user")).toBeNull(); + expect(invalidations).toBe(1); + } finally { + unsubscribe(); + } + }); + + test("an ordinary request adopts the exact generation produced by its own resumption", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + const before = userAuthority(); + client.retireAuthenticationState(API_URL, "user"); + + const response = await client.fetch({ + ...operation("/protected/user", before), + method: "GET" + }); + + expect(response.ok).toBe(true); + expect(userAuthority().generation).toBe(before.generation + 1); + expect(server.requests.slice(-2).map((request) => request.request.path)).toEqual([ + "/refresh", + "/protected/user" + ]); + }); + + test("terminal operations use the exact generation produced by their own resumption", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + const before = userAuthority(); + client.retireAuthenticationState(API_URL, "user"); + + const changed = await client.fetch( + operation("/protected/change_password", before, { + current_password: "old", + new_password: "new" + }) + ); + expect(changed.ok).toBe(true); + const afterChange = userAuthority(); + expect(afterChange.generation).toBe(before.generation + 2); + + const loggedOut = await client.fetch(operation("/logout", afterChange, {})); + expect(loggedOut.ok).toBe(true); + expect(readTransportV2Credentials(API_URL, "user")).toBeNull(); + }); + + test("a delayed logout cannot clear or retire a newer principal", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + const paused = pausePath(server, "/logout"); + const pending = client.fetch(operation("/logout", userAuthority(), {})); + await paused.reached; + const newer = installUserCredentials("11112233-4455-6677-8899-aabbccddeeff"); + paused.release(); + + await expect(pending).rejects.toThrow("authentication state changed"); + expect(readTransportV2Credentials(API_URL, "user")).toEqual(newer); + }); + + test("does not replay after an ambiguous ordinary request send", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + server.failAfterPath = "/v1/models"; + await expect( + client.fetch(operation("/v1/models", { kind: "anonymous", purpose: "public" })) + ).rejects.toThrow("ambiguous /v1/models failure"); + expect(server.requestCount).toBe(1); + await expect( + client.fetch(operation("/v1/models", { kind: "anonymous", purpose: "public" })) + ).resolves.toBeInstanceOf(Response); + expect(server.keyExchangeCount).toBe(2); + }); + + test("retires an exhausted bound session without retrying and replaces it next request", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies(2)); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + await client.fetch({ ...operation("/protected/user", userAuthority()), method: "GET" }); + const sentBeforeExhaustion = server.requestCount; + + await expect( + client.fetch({ ...operation("/protected/user", userAuthority()), method: "GET" }) + ).rejects.toThrow("response record budget is exhausted"); + expect(server.requestCount).toBe(sentBeforeExhaustion); + + await expect( + client.fetch({ ...operation("/protected/user", userAuthority()), method: "GET" }) + ).resolves.toBeInstanceOf(Response); + expect(server.keyExchangeCount).toBe(2); + expect(server.requestCount).toBe(sentBeforeExhaustion + 2); + }); + + test("retires only an authenticated unary session_exhausted response for the next request", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + server.sessionExhaustedOncePath = "/protected/user"; + const sentBefore = server.requestCount; + + const exhausted = await client.fetch({ + ...operation("/protected/user", userAuthority()), + method: "GET" + }); + expect(exhausted.status).toBe(503); + expect(await exhausted.json()).toEqual({ + error: { + code: "session_exhausted", + message: "Session request capacity is exhausted" + } + }); + expect(server.requestCount).toBe(sentBefore + 1); + + await client.fetch({ ...operation("/protected/user", userAuthority()), method: "GET" }); + expect(server.keyExchangeCount).toBe(2); + expect(server.requestCount).toBe(sentBefore + 3); + }); + + test("does not retire a generic authenticated 503 application response", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + server.genericServiceUnavailableOncePath = "/protected/user"; + + const unavailable = await client.fetch({ + ...operation("/protected/user", userAuthority()), + method: "GET" + }); + expect(unavailable.status).toBe(503); + await client.fetch({ ...operation("/protected/user", userAuthority()), method: "GET" }); + expect(server.keyExchangeCount).toBe(1); + }); + + test("retires an authenticated pre-start stream session_exhausted response", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + server.preStartSessionExhaustedOncePath = "/v1/responses"; + + const exhausted = await client.fetch( + operation("/v1/responses", userAuthority(), { model: "test" }, "stream") + ); + expect(exhausted.status).toBe(503); + expect((await exhausted.json()).error.code).toBe("session_exhausted"); + await client.fetch({ ...operation("/protected/user", userAuthority()), method: "GET" }); + expect(server.keyExchangeCount).toBe(2); + }); + + test("retires an authenticated late stream session_exhausted terminal", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + server.streamSessionExhaustedOncePath = "/v1/responses"; + + const exhausted = await client.fetch( + operation("/v1/responses", userAuthority(), { model: "test" }, "stream") + ); + await expect(exhausted.text()).rejects.toThrow('"code":"session_exhausted"'); + await client.fetch({ ...operation("/protected/user", userAuthority()), method: "GET" }); + expect(server.keyExchangeCount).toBe(2); + }); + + test("reconstructs an authenticated SSE stream through its exact terminal record", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + const response = await client.fetch( + operation("/v1/responses", userAuthority(), { model: "test" }, "stream") + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + expect(await response.text()).toBe("event: response.completed\ndata: {}\n\n"); + }); + + test("rejects a carrier chunk arriving after the authenticated stream terminal", async () => { + const server = new V2TestServer(); + server.appendStreamRecordAfterEnd = true; + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + const response = await client.fetch( + operation("/v1/responses", userAuthority(), { model: "test" }, "stream") + ); + await expect(response.text()).rejects.toThrow( + "Transport v2 stream contains data after its terminal record" + ); + }); + + test("a late failure from an old stream cannot retire its replacement session", async () => { + const server = new V2TestServer(); + server.appendStreamRecordAfterEnd = true; + let releaseTrailing!: () => void; + server.streamTrailingGate = new Promise((resolve) => { + releaseTrailing = resolve; + }); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + const oldResponse = await client.fetch( + operation("/v1/responses", userAuthority(), { model: "test" }, "stream") + ); + + client.retireAuthenticationState(API_URL, "user"); + await client.fetch({ + ...operation("/protected/user", userAuthority()), + method: "GET" + }); + expect(server.keyExchangeCount).toBe(2); + + releaseTrailing(); + await expect(oldResponse.text()).rejects.toThrow( + "Transport v2 stream contains data after its terminal record" + ); + await client.fetch({ + ...operation("/protected/user", userAuthority()), + method: "GET" + }); + expect(server.keyExchangeCount).toBe(2); + }); + + test("retires a bound user session after an ambiguous logout", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + server.failAfterPath = "/logout"; + await expect(client.fetch(operation("/logout", userAuthority(), {}))).rejects.toThrow( + "ambiguous /logout failure" + ); + await expect( + client.fetch({ ...operation("/protected/user", userAuthority()), method: "GET" }) + ).resolves.toBeInstanceOf(Response); + expect(server.keyExchangeCount).toBe(2); + }); + + test("local logout cleanup follows only the exact refresh produced by that logout", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + const expected = snapshotTransportV2Auth(API_URL, "user"); + client.retireAuthenticationState(API_URL, "user"); + server.failAfterPath = "/logout"; + + await expect(client.fetch(operation("/logout", userAuthority(), {}))).rejects.toThrow( + "ambiguous /logout failure" + ); + expect(readTransportV2Credentials(API_URL, "user")?.generation).toBe(expected.generation + 1); + expect(client.clear(API_URL, "user", false, expected)).toBe(true); + expect(readTransportV2Credentials(API_URL, "user")).toBeNull(); + }); + + test("refresh-successor logout cleanup cannot clear a newer login", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + const expected = snapshotTransportV2Auth(API_URL, "user"); + client.retireAuthenticationState(API_URL, "user"); + server.failAfterPath = "/logout"; + await expect(client.fetch(operation("/logout", userAuthority(), {}))).rejects.toThrow( + "ambiguous /logout failure" + ); + + const newer = installUserCredentials("11112233-4455-6677-8899-aabbccddeeff"); + expect(client.clear(API_URL, "user", false, expected)).toBe(false); + expect(readTransportV2Credentials(API_URL, "user")).toEqual(newer); + }); + + for (const provider of ["github", "google", "apple"] as const) { + test(`restores and consumes the exact ${provider} OAuth session after reload`, async () => { + const server = new V2TestServer(); + const initiator = new TransportV2Client(server.dependencies()); + const initiated = await initiator.fetch( + operation(`/auth/${provider}`, { kind: "anonymous", purpose: "user" }, { client_id: "c" }) + ); + const state = ((await initiated.json()) as { state: string }).state; + expect(state).toBe(`${provider}-state`); + expect(sessionStorage.length).toBe(1); + + const callback = new TransportV2Client(server.dependencies()); + const completed = await callback.fetch( + operation( + `/auth/${provider}/callback`, + { kind: "anonymous", purpose: "user" }, + { + code: "code", + state + } + ) + ); + expect(completed.ok).toBe(true); + expect(sessionStorage.length).toBe(0); + expect(server.keyExchangeCount).toBe(1); + await expect( + new TransportV2Client(server.dependencies()).fetch( + operation( + `/auth/${provider}/callback`, + { kind: "anonymous", purpose: "user" }, + { + code: "code", + state + } + ) + ) + ).rejects.toThrow("unavailable"); + }); + } + + test("does not consume an OAuth continuation for a wrong state, policy, or provider", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/auth/github", { kind: "anonymous", purpose: "user" }, { client_id: "c" }) + ); + const callback = new TransportV2Client(server.dependencies()); + await expect( + callback.fetch( + operation( + "/auth/github/callback", + { kind: "anonymous", purpose: "user" }, + { + code: "code", + state: "wrong" + } + ) + ) + ).rejects.toThrow("does not match"); + await expect( + callback.fetch({ + ...operation( + "/auth/github/callback", + { kind: "anonymous", purpose: "user" }, + { + code: "code", + state: "github-state" + } + ), + pcrConfig: { pcr0Values: ["a".repeat(96)], remoteAttestation: false } + }) + ).rejects.toThrow("unavailable"); + await expect( + callback.fetch( + operation( + "/auth/google/callback", + { kind: "anonymous", purpose: "user" }, + { + code: "code", + state: "github-state" + } + ) + ) + ).rejects.toThrow("unavailable"); + const otherApiUrl = "http://127.0.0.1:3010/other"; + await expect( + callback.fetch({ + ...operation( + "/auth/github/callback", + { kind: "anonymous", purpose: "user" }, + { + code: "code", + state: "github-state" + } + ), + apiUrl: otherApiUrl, + url: `${otherApiUrl}/auth/github/callback` + }) + ).rejects.toThrow("unavailable"); + expect(sessionStorage.length).toBe(1); + }); + + test("removes an expired OAuth continuation", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/auth/github", { kind: "anonymous", purpose: "user" }, { client_id: "c" }) + ); + const key = sessionStorage.key(0)!; + const continuation = JSON.parse(sessionStorage.getItem(key)!) as { + session: { expiresAtUnixSeconds: number }; + }; + continuation.session.expiresAtUnixSeconds = 0; + sessionStorage.setItem(key, JSON.stringify(continuation)); + await expect( + new TransportV2Client(server.dependencies()).fetch( + operation( + "/auth/github/callback", + { kind: "anonymous", purpose: "user" }, + { + code: "code", + state: "github-state" + } + ) + ) + ).rejects.toThrow("expired"); + expect(sessionStorage.length).toBe(0); + }); + + test("cleans a failed API-key establishment gate for the next concurrent caller", async () => { + const server = new V2TestServer(); + server.failKeyExchanges = 1; + const client = new TransportV2Client(server.dependencies()); + const authority = { kind: "api_key" as const, value: "api-key" }; + const [first, second] = await Promise.allSettled([ + client.fetch(operation("/v1/models", authority)), + client.fetch(operation("/v1/models", authority)) + ]); + expect(first.status).toBe("rejected"); + expect(second.status).toBe("fulfilled"); + expect(server.keyExchangeCount).toBe(2); + await expect(client.fetch(operation("/v1/models", authority))).resolves.toBeInstanceOf( + Response + ); + expect(server.keyExchangeCount).toBe(2); + }); + + test("retiring an in-flight API-key bind prevents the old completion from repopulating it", async () => { + const server = new V2TestServer(); + const paused = pausePath(server, "/v1/models"); + const client = new TransportV2Client(server.dependencies()); + const authority = { kind: "api_key" as const, value: "api-key" }; + const pending = client.fetch(operation("/v1/models", authority)); + await paused.reached; + await client.retireApiKey(API_URL, { environment: "development" }, "api-key"); + paused.release(); + + await expect(pending).rejects.toThrow("authentication state changed"); + await expect(client.fetch(operation("/v1/models", authority))).resolves.toBeInstanceOf( + Response + ); + expect(server.keyExchangeCount).toBe(2); + }); + + test("re-establishes an API-key session at its exact expiry", async () => { + const originalNow = Date.now; + let now = originalNow(); + Date.now = () => now; + try { + const server = new V2TestServer(); + server.sessionLifetimeSeconds = 1; + const client = new TransportV2Client(server.dependencies()); + const authority = { kind: "api_key" as const, value: "api-key" }; + await client.fetch(operation("/v1/models", authority)); + now += 1000; + await client.fetch(operation("/v1/models", authority)); + expect(server.keyExchangeCount).toBe(2); + } finally { + Date.now = originalNow; + } + }); + + test("retires bound user and API-key sessions after an authenticated unauthorized result", async () => { + const server = new V2TestServer(); + const client = new TransportV2Client(server.dependencies()); + await client.fetch( + operation("/login", { kind: "anonymous", purpose: "user" }, { email: "a", password: "b" }) + ); + server.unauthorizedOncePath = "/protected/user"; + const unauthorizedUser = await client.fetch({ + ...operation("/protected/user", userAuthority()), + method: "GET" + }); + expect(unauthorizedUser.status).toBe(401); + await client.fetch({ ...operation("/protected/user", userAuthority()), method: "GET" }); + expect(server.keyExchangeCount).toBe(2); + + const authority = { kind: "api_key" as const, value: "api-key" }; + await client.fetch(operation("/v1/models", authority)); + server.unauthorizedOncePath = "/v1/models"; + const unauthorizedApiKey = await client.fetch(operation("/v1/models", authority)); + expect(unauthorizedApiKey.status).toBe(401); + await client.fetch(operation("/v1/models", authority)); + expect(server.keyExchangeCount).toBe(4); + }); +}); diff --git a/sdk/src/lib/test/transportV2Refresh.test.ts b/sdk/src/lib/test/transportV2Refresh.test.ts new file mode 100644 index 000000000..220c2690d --- /dev/null +++ b/sdk/src/lib/test/transportV2Refresh.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; +import { getApiPcrConfig, getApiUrl, refreshToken, setApiUrl } from "../api"; +import { + getPlatformApiUrl, + getPlatformPcrConfig, + platformRefreshToken, + setPlatformApiUrl +} from "../platformApi"; +import { clearTransportV2Credentials, installTransportV2Credentials } from "../transportV2/auth"; +import { transportV2Client } from "../transportV2/client"; + +const USER_API_URL = "https://user-refresh.example.test/base"; +const PLATFORM_API_URL = "https://platform-refresh.example.test/base"; +const originalUserUrl = getApiUrl(); +const originalUserPcr = getApiPcrConfig(); +const originalPlatformUrl = getPlatformApiUrl(); +const originalPlatformPcr = getPlatformPcrConfig(); + +function token( + principalKind: "user" | "platform", + tokenKind: "access_descriptor" | "resumption", + principalId: string +): string { + const audience = `urn:opensecret:internal:transport-v2:${principalKind}:${ + tokenKind === "access_descriptor" ? "access-descriptor" : "resumption" + }`; + const payload = Buffer.from( + JSON.stringify({ + iss: "urn:opensecret:transport-v2", + aud: audience, + tv: 2, + tk: tokenKind, + pk: principalKind, + sub: principalId, + exp: 2_000_000_000 + }) + ).toString("base64url"); + return `e30.${payload}.c2ln`; +} + +function credentials(kind: "user" | "platform", principalId: string) { + return { + access_token: token(kind, "access_descriptor", principalId), + refresh_token: token(kind, "resumption", principalId) + }; +} + +beforeEach(() => { + localStorage.clear(); + setApiUrl(USER_API_URL, { environment: "development", remoteAttestation: false }); + setPlatformApiUrl(PLATFORM_API_URL, { + environment: "production", + remoteAttestation: false + }); +}); + +afterEach(() => { + mock.restore(); + clearTransportV2Credentials(USER_API_URL); + clearTransportV2Credentials(PLATFORM_API_URL); + localStorage.clear(); + setApiUrl(originalUserUrl, originalUserPcr); + setPlatformApiUrl(originalPlatformUrl, originalPlatformPcr); +}); + +test("user refresh uses the anonymous v2 resumption transition without generic tokens", async () => { + const current = credentials("user", "user-123"); + const next = credentials("user", "user-123"); + installTransportV2Credentials(USER_API_URL, "user", current.access_token, current.refresh_token); + const refresh = spyOn(transportV2Client, "refresh").mockImplementation( + async (apiUrl, kind, pcrConfig) => { + expect(apiUrl).toBe(USER_API_URL); + expect(kind).toBe("user"); + expect(pcrConfig).toMatchObject({ environment: "development", remoteAttestation: false }); + return Response.json(next); + } + ); + + await expect(refreshToken()).resolves.toEqual(next); + expect(refresh).toHaveBeenCalledTimes(1); + expect(localStorage.getItem("access_token")).toBeNull(); + expect(localStorage.getItem("refresh_token")).toBeNull(); +}); + +test("platform refresh uses the separate anonymous v2 resumption transition", async () => { + const current = credentials("platform", "platform-123"); + const next = credentials("platform", "platform-123"); + installTransportV2Credentials( + PLATFORM_API_URL, + "platform", + current.access_token, + current.refresh_token + ); + const refresh = spyOn(transportV2Client, "refresh").mockImplementation( + async (apiUrl, kind, pcrConfig) => { + expect(apiUrl).toBe(PLATFORM_API_URL); + expect(kind).toBe("platform"); + expect(pcrConfig).toMatchObject({ environment: "production", remoteAttestation: false }); + return Response.json(next); + } + ); + + await expect(platformRefreshToken()).resolves.toEqual(next); + expect(refresh).toHaveBeenCalledTimes(1); + expect(localStorage.getItem("access_token")).toBeNull(); + expect(localStorage.getItem("refresh_token")).toBeNull(); +}); diff --git a/sdk/src/lib/transportV2/auth.ts b/sdk/src/lib/transportV2/auth.ts new file mode 100644 index 000000000..67d915807 --- /dev/null +++ b/sdk/src/lib/transportV2/auth.ts @@ -0,0 +1,637 @@ +import { decode, decodeURLSafe, encode, encodeURLSafe } from "@stablelib/base64"; +import { decodeUtf8, encodeUtf8, parseStrictJson, requireExactObject } from "./encoding"; + +const TOKEN_LIMIT_BYTES = 16 * 1024; +const AUTH_BUNDLE_LIMIT_BYTES = 64 * 1024; +const AUTH_STORAGE_PREFIX = "opensecret:transport-v2:auth:v1:"; +const CACHE_ROOT_STORAGE_PREFIX = "opensecret:transport-v2:cache-root:v1:"; + +const USER_ACCESS_AUDIENCE = "urn:opensecret:internal:transport-v2:user:access-descriptor"; +const USER_RESUMPTION_AUDIENCE = "urn:opensecret:internal:transport-v2:user:resumption"; +const PLATFORM_ACCESS_AUDIENCE = "urn:opensecret:internal:transport-v2:platform:access-descriptor"; +const PLATFORM_RESUMPTION_AUDIENCE = "urn:opensecret:internal:transport-v2:platform:resumption"; +const TOKEN_ISSUER = "urn:opensecret:transport-v2"; + +export type TransportV2PrincipalKind = "user" | "platform"; + +export interface StoredTransportV2Credentials { + kind: TransportV2PrincipalKind; + principalId: string; + apiOrigin: string; + generation: number; + accessToken: string; + refreshToken: string; + accessExpiresAtUnixSeconds: number; +} + +/** A process-local compare-and-swap token for one persisted authority slot. */ +export interface TransportV2AuthSnapshot { + kind: TransportV2PrincipalKind; + principalId: string | null; + apiOrigin: string; + generation: number; +} + +export class TransportV2AuthorityChangedError extends Error { + constructor() { + super("Transport v2 authentication state changed while the operation was in progress."); + this.name = "TransportV2AuthorityChangedError"; + } +} + +interface StoredAuthMarker { + version: 2; + principal_kind: TransportV2PrincipalKind; + principal_id: string; + api_origin: string; + generation: number; + access_expires_at_unix_seconds: number; +} + +interface TransportV2AuthBundle { + version: 2; + api_origin: string; + access_token: string; + refresh_token: string; + cache_namespace_root_base64: string; +} + +export interface PreparedTransportV2AuthBundleImport { + apiOrigin: string; + accessToken: string; + refreshToken: string; + cacheNamespaceRoot: Uint8Array; +} + +interface TokenHints { + kind: TransportV2PrincipalKind; + principalId: string; + expiresAtUnixSeconds: number; +} + +const memoryStorage = new Map(); +const authInvalidationListeners = new Set<{ + apiOrigin: string; + kind: TransportV2PrincipalKind; + listener: () => void; +}>(); + +function storage(): Storage | undefined { + try { + return globalThis.localStorage; + } catch { + return undefined; + } +} + +function readStorage(key: string): string | null { + try { + return storage()?.getItem(key) ?? memoryStorage.get(key) ?? null; + } catch { + return memoryStorage.get(key) ?? null; + } +} + +function writeStorage(key: string, value: string): void { + memoryStorage.set(key, value); + try { + storage()?.setItem(key, value); + } catch { + // Sandboxed browser contexts may not expose persistent storage. The + // in-memory value remains valid for this process. + } +} + +function removeStorage(key: string): void { + memoryStorage.delete(key); + try { + storage()?.removeItem(key); + } catch { + // Best-effort removal from unavailable storage; the in-memory copy is gone. + } +} + +function withoutBase64Padding(value: string): string { + return value.replace(/=+$/u, ""); +} + +function paddedBase64Url(value: string): string { + if (!/^[A-Za-z0-9_-]+$/u.test(value)) { + throw new Error("Transport v2 value is not canonical base64url."); + } + const remainder = value.length % 4; + if (remainder === 1) { + throw new Error("Transport v2 value is not canonical base64url."); + } + return `${value}${"=".repeat((4 - remainder) % 4)}`; +} + +function decodeUnpaddedBase64Url(value: string, limit: number): Uint8Array { + if (encodeUtf8(value).length > Math.ceil(limit / 3) * 4) { + throw new Error("Transport v2 encoded value exceeds its size limit."); + } + const decoded = decodeURLSafe(paddedBase64Url(value)); + if (decoded.length > limit || withoutBase64Padding(encodeURLSafe(decoded)) !== value) { + decoded.fill(0); + throw new Error("Transport v2 value is not canonical base64url."); + } + return decoded; +} + +function storageScope(apiOrigin: string): string { + return withoutBase64Padding(encodeURLSafe(encodeUtf8(apiOrigin))); +} + +function authStorageKey(kind: TransportV2PrincipalKind, apiOrigin: string, field: string): string { + return `${AUTH_STORAGE_PREFIX}${kind}:${storageScope(apiOrigin)}:${field}`; +} + +function readAuthGeneration(apiOrigin: string, kind: TransportV2PrincipalKind): number { + const raw = readStorage(authStorageKey(kind, apiOrigin, "generation")); + if (raw === null) return 0; + if (!/^(?:0|[1-9][0-9]*)$/u.test(raw)) return 0; + const value = Number(raw); + return Number.isSafeInteger(value) && value >= 0 ? value : 0; +} + +function nextAuthGeneration(current: number): number { + if (!Number.isSafeInteger(current) || current < 0 || current >= Number.MAX_SAFE_INTEGER) { + throw new Error("Transport v2 authentication generation is exhausted."); + } + return current + 1; +} + +function removeCredentialFields(apiOrigin: string, kind: TransportV2PrincipalKind): void { + for (const field of ["marker", "access", "refresh"] as const) { + removeStorage(authStorageKey(kind, apiOrigin, field)); + } +} + +function clearCredentialFieldsAtGeneration( + apiOrigin: string, + kind: TransportV2PrincipalKind, + generation: number, + notify: boolean +): boolean { + if (readAuthGeneration(apiOrigin, kind) !== generation) return false; + removeCredentialFields(apiOrigin, kind); + writeStorage( + authStorageKey(kind, apiOrigin, "generation"), + String(nextAuthGeneration(generation)) + ); + if (notify) notifyAuthInvalidated(apiOrigin, kind); + return true; +} + +function notifyAuthInvalidated(apiOrigin: string, kind: TransportV2PrincipalKind): void { + for (const subscription of authInvalidationListeners) { + if (subscription.apiOrigin === apiOrigin && subscription.kind === kind) { + subscription.listener(); + } + } +} + +function cacheRootStorageKey(apiOrigin: string): string { + return `${CACHE_ROOT_STORAGE_PREFIX}${storageScope(apiOrigin)}`; +} + +export function canonicalizeTransportV2ApiUrl(apiUrl: string): string { + let url: URL; + try { + url = new URL(apiUrl); + } catch { + throw new Error("Transport v2 requires a valid API URL."); + } + + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error("Transport v2 API URL must use HTTP or HTTPS."); + } + if (url.username || url.password || url.search || url.hash) { + throw new Error("Transport v2 API URL must not contain credentials, a query, or a fragment."); + } + const localHosts = new Set(["127.0.0.1", "localhost", "0.0.0.0", "[::1]"]); + if (url.protocol !== "https:" && !localHosts.has(url.hostname.toLowerCase())) { + throw new Error("Transport v2 requires HTTPS outside exact loopback development."); + } + + const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/u, ""); + return `${url.origin}${path}`; +} + +function parseTokenClaims(token: string): Record { + if (encodeUtf8(token).length === 0 || encodeUtf8(token).length > TOKEN_LIMIT_BYTES) { + throw new Error("Transport v2 credential has an invalid length."); + } + const parts = token.split("."); + if (parts.length !== 3 || parts.some((part) => part.length === 0)) { + throw new Error("Transport v2 credential is not a JWT."); + } + const payload = decodeUnpaddedBase64Url(parts[1], TOKEN_LIMIT_BYTES); + try { + const parsed = parseStrictJson(decodeUtf8(payload)); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("Transport v2 credential claims are invalid."); + } + return parsed as Record; + } finally { + payload.fill(0); + } +} + +function tokenHints( + token: string, + expectedKind: TransportV2PrincipalKind, + expectedTokenKind: "access_descriptor" | "resumption" +): TokenHints { + const claims = parseTokenClaims(token); + const expectedAudience = + expectedKind === "user" + ? expectedTokenKind === "access_descriptor" + ? USER_ACCESS_AUDIENCE + : USER_RESUMPTION_AUDIENCE + : expectedTokenKind === "access_descriptor" + ? PLATFORM_ACCESS_AUDIENCE + : PLATFORM_RESUMPTION_AUDIENCE; + if ( + claims.iss !== TOKEN_ISSUER || + claims.aud !== expectedAudience || + claims.tv !== 2 || + claims.tk !== expectedTokenKind || + claims.pk !== expectedKind || + typeof claims.sub !== "string" || + claims.sub.length === 0 || + typeof claims.exp !== "number" || + !Number.isSafeInteger(claims.exp) || + claims.exp <= 0 + ) { + throw new Error("Transport v2 credential descriptor is invalid."); + } + return { + kind: expectedKind, + principalId: claims.sub, + expiresAtUnixSeconds: claims.exp + }; +} + +function parseStoredMarker(value: string): StoredAuthMarker { + const marker = requireExactObject( + parseStrictJson(value), + [ + "version", + "principal_kind", + "principal_id", + "api_origin", + "generation", + "access_expires_at_unix_seconds" + ], + "Transport v2 auth marker" + ); + if ( + marker.version !== 2 || + (marker.principal_kind !== "user" && marker.principal_kind !== "platform") || + typeof marker.principal_id !== "string" || + marker.principal_id.length === 0 || + typeof marker.api_origin !== "string" || + typeof marker.generation !== "number" || + !Number.isSafeInteger(marker.generation) || + marker.generation <= 0 || + typeof marker.access_expires_at_unix_seconds !== "number" || + !Number.isSafeInteger(marker.access_expires_at_unix_seconds) + ) { + throw new Error("Transport v2 auth marker is invalid."); + } + return marker as unknown as StoredAuthMarker; +} + +export function installTransportV2Credentials( + apiUrl: string, + kind: TransportV2PrincipalKind, + accessToken: string, + refreshToken: string, + expected?: TransportV2AuthSnapshot +): StoredTransportV2Credentials { + const apiOrigin = canonicalizeTransportV2ApiUrl(apiUrl); + const access = tokenHints(accessToken, kind, "access_descriptor"); + const resumption = tokenHints(refreshToken, kind, "resumption"); + if (access.principalId !== resumption.principalId) { + throw new Error("Transport v2 credential principals do not match."); + } + + const current = snapshotTransportV2Auth(apiOrigin, kind); + if ( + expected && + (expected.apiOrigin !== apiOrigin || + expected.kind !== kind || + !isTransportV2AuthSnapshotCurrent(expected)) + ) { + throw new TransportV2AuthorityChangedError(); + } + const generation = nextAuthGeneration(current.generation); + + const marker: StoredAuthMarker = { + version: 2, + principal_kind: kind, + principal_id: access.principalId, + api_origin: apiOrigin, + generation, + access_expires_at_unix_seconds: access.expiresAtUnixSeconds + }; + // The marker is the commit record. JavaScript storage calls do not yield, so + // readers in this process observe either the old generation or this complete + // new generation, never a partially installed identity. + writeStorage(authStorageKey(kind, apiOrigin, "access"), accessToken); + writeStorage(authStorageKey(kind, apiOrigin, "refresh"), refreshToken); + writeStorage(authStorageKey(kind, apiOrigin, "marker"), JSON.stringify(marker)); + writeStorage(authStorageKey(kind, apiOrigin, "generation"), String(generation)); + // React identity is invalidated only when the principal changes. Ordinary + // same-principal refresh/import rotations are generation-fenced by the + // transport manager and do not make the already-rendered identity false. + if (current.principalId !== null && current.principalId !== access.principalId) { + notifyAuthInvalidated(apiOrigin, kind); + } + + return { + kind, + principalId: access.principalId, + apiOrigin, + generation, + accessToken, + refreshToken, + accessExpiresAtUnixSeconds: access.expiresAtUnixSeconds + }; +} + +export function readTransportV2Credentials( + apiUrl: string, + kind: TransportV2PrincipalKind +): StoredTransportV2Credentials | null { + const apiOrigin = canonicalizeTransportV2ApiUrl(apiUrl); + const markerKey = authStorageKey(kind, apiOrigin, "marker"); + const accessKey = authStorageKey(kind, apiOrigin, "access"); + const refreshKey = authStorageKey(kind, apiOrigin, "refresh"); + const markerValue = readStorage(markerKey); + const accessToken = readStorage(accessKey); + const refreshToken = readStorage(refreshKey); + const generation = readAuthGeneration(apiOrigin, kind); + if (!markerValue && !accessToken && !refreshToken) return null; + + try { + if (!markerValue || !accessToken || !refreshToken) { + throw new Error("Transport v2 stored credentials are incomplete."); + } + const marker = parseStoredMarker(markerValue); + const access = tokenHints(accessToken, kind, "access_descriptor"); + const resumption = tokenHints(refreshToken, kind, "resumption"); + if ( + marker.api_origin !== apiOrigin || + marker.principal_kind !== kind || + marker.generation !== generation || + marker.principal_id !== access.principalId || + marker.principal_id !== resumption.principalId || + marker.access_expires_at_unix_seconds !== access.expiresAtUnixSeconds + ) { + throw new Error("Transport v2 stored credential binding is invalid."); + } + return { + kind, + principalId: access.principalId, + apiOrigin, + generation, + accessToken, + refreshToken, + accessExpiresAtUnixSeconds: access.expiresAtUnixSeconds + }; + } catch { + const principalId = (() => { + try { + return markerValue ? parseStoredMarker(markerValue).principal_id : null; + } catch { + return null; + } + })(); + clearCredentialFieldsAtGeneration(apiOrigin, kind, generation, principalId !== null); + return null; + } +} + +export function snapshotTransportV2Auth( + apiUrl: string, + kind: TransportV2PrincipalKind +): TransportV2AuthSnapshot { + const apiOrigin = canonicalizeTransportV2ApiUrl(apiUrl); + const credentials = readTransportV2Credentials(apiOrigin, kind); + return { + kind, + principalId: credentials?.principalId ?? null, + apiOrigin, + generation: credentials?.generation ?? readAuthGeneration(apiOrigin, kind) + }; +} + +export function isTransportV2AuthSnapshotCurrent(snapshot: TransportV2AuthSnapshot): boolean { + const apiOrigin = canonicalizeTransportV2ApiUrl(snapshot.apiOrigin); + if (apiOrigin !== snapshot.apiOrigin) return false; + if (readAuthGeneration(apiOrigin, snapshot.kind) !== snapshot.generation) return false; + const markerValue = readStorage(authStorageKey(snapshot.kind, apiOrigin, "marker")); + if (!markerValue) return snapshot.principalId === null; + try { + const marker = parseStoredMarker(markerValue); + return ( + marker.api_origin === apiOrigin && + marker.principal_kind === snapshot.kind && + marker.generation === snapshot.generation && + marker.principal_id === snapshot.principalId + ); + } catch { + return false; + } +} + +export function clearTransportV2CredentialsIfCurrent(expected: TransportV2AuthSnapshot): boolean { + const apiOrigin = canonicalizeTransportV2ApiUrl(expected.apiOrigin); + if (apiOrigin !== expected.apiOrigin || !isTransportV2AuthSnapshotCurrent(expected)) return false; + return clearCredentialFieldsAtGeneration( + apiOrigin, + expected.kind, + expected.generation, + expected.principalId !== null + ); +} + +export function clearTransportV2Credentials(apiUrl: string, kind?: TransportV2PrincipalKind): void { + const apiOrigin = canonicalizeTransportV2ApiUrl(apiUrl); + for (const selectedKind of kind ? [kind] : (["user", "platform"] as const)) { + clearTransportV2CredentialsIfCurrent(snapshotTransportV2Auth(apiOrigin, selectedKind)); + } + removeStorage("access_token"); + removeStorage("refresh_token"); +} + +/** @internal Used by React providers to mirror exact current credential invalidation. */ +export function subscribeTransportV2AuthInvalidation( + apiUrl: string, + kind: TransportV2PrincipalKind, + listener: () => void +): () => void { + const subscription = { + apiOrigin: canonicalizeTransportV2ApiUrl(apiUrl), + kind, + listener + }; + authInvalidationListeners.add(subscription); + return () => authInvalidationListeners.delete(subscription); +} + +export function clearLegacyTransportV1Credentials(): void { + removeStorage("access_token"); + removeStorage("refresh_token"); +} + +export function getOrCreateTransportV2CacheRoot( + apiUrl: string, + random: Crypto = globalThis.crypto +): Uint8Array { + // One client-held root is intentionally stable per canonical API base. The + // enclave combines it with the authenticated owner, so user and API-key + // sessions for the same owner can share a provider-cache namespace while + // the same root cannot collide across different verified owners. + const apiOrigin = canonicalizeTransportV2ApiUrl(apiUrl); + const key = cacheRootStorageKey(apiOrigin); + const existing = readStorage(key); + if (existing) { + try { + const decoded = decode(existing); + if (decoded.length === 32 && encode(decoded) === existing) return decoded; + decoded.fill(0); + } catch { + // Replace malformed local state with a fresh client-held root. + } + removeStorage(key); + } + const root = new Uint8Array(32); + random.getRandomValues(root); + writeStorage(key, encode(root)); + return root; +} + +export function setTransportV2CacheRoot(apiUrl: string, root: Uint8Array): void { + if (root.length !== 32) throw new Error("Transport v2 cache namespace root must be 32 bytes."); + const apiOrigin = canonicalizeTransportV2ApiUrl(apiUrl); + writeStorage(cacheRootStorageKey(apiOrigin), encode(root)); +} + +export function clearTransportV2CacheRoot(apiUrl: string): void { + removeStorage(cacheRootStorageKey(canonicalizeTransportV2ApiUrl(apiUrl))); +} + +export async function exportTransportV2AuthBundle(apiUrl: string): Promise { + const apiOrigin = canonicalizeTransportV2ApiUrl(apiUrl); + const credentials = readTransportV2Credentials(apiOrigin, "user"); + if (!credentials) throw new Error("No transport v2 user credentials are available to export."); + const root = getOrCreateTransportV2CacheRoot(apiOrigin); + try { + const bundle: TransportV2AuthBundle = { + version: 2, + api_origin: apiOrigin, + access_token: credentials.accessToken, + refresh_token: credentials.refreshToken, + cache_namespace_root_base64: encode(root) + }; + return withoutBase64Padding(encodeURLSafe(encodeUtf8(JSON.stringify(bundle)))); + } finally { + root.fill(0); + } +} + +export function prepareTransportV2AuthBundleImport( + bundle: string, + expectedApiUrl: string +): PreparedTransportV2AuthBundleImport { + const bytes = decodeUnpaddedBase64Url(bundle, AUTH_BUNDLE_LIMIT_BYTES); + let root: Uint8Array | undefined; + try { + const value = requireExactObject( + parseStrictJson(decodeUtf8(bytes)), + ["version", "api_origin", "access_token", "refresh_token", "cache_namespace_root_base64"], + "Transport v2 auth bundle" + ); + if ( + value.version !== 2 || + typeof value.api_origin !== "string" || + typeof value.access_token !== "string" || + typeof value.refresh_token !== "string" || + typeof value.cache_namespace_root_base64 !== "string" + ) { + throw new Error("Transport v2 auth bundle is invalid."); + } + const apiOrigin = canonicalizeTransportV2ApiUrl(value.api_origin); + if (apiOrigin !== canonicalizeTransportV2ApiUrl(expectedApiUrl)) { + throw new Error("Transport v2 auth bundle belongs to a different API origin."); + } + root = decode(value.cache_namespace_root_base64); + if (root.length !== 32 || encode(root) !== value.cache_namespace_root_base64) { + throw new Error("Transport v2 auth bundle cache root is invalid."); + } + const canonicalBundle: TransportV2AuthBundle = { + version: 2, + api_origin: apiOrigin, + access_token: value.access_token, + refresh_token: value.refresh_token, + cache_namespace_root_base64: value.cache_namespace_root_base64 + }; + const canonicalEncoding = withoutBase64Padding( + encodeURLSafe(encodeUtf8(JSON.stringify(canonicalBundle))) + ); + if (canonicalEncoding !== bundle) { + throw new Error("Transport v2 auth bundle is not canonically encoded."); + } + // Validate both descriptors before the caller retires a currently usable + // bound session. The backend remains authoritative when this resumption + // credential is actually presented. + const access = tokenHints(value.access_token, "user", "access_descriptor"); + const resumption = tokenHints(value.refresh_token, "user", "resumption"); + if (access.principalId !== resumption.principalId) { + throw new Error("Transport v2 credential principals do not match."); + } + const prepared = { + apiOrigin, + accessToken: value.access_token, + refreshToken: value.refresh_token, + cacheNamespaceRoot: root + }; + root = undefined; + return prepared; + } finally { + root?.fill(0); + bytes.fill(0); + } +} + +export function commitTransportV2AuthBundleImport( + prepared: PreparedTransportV2AuthBundleImport, + expected = snapshotTransportV2Auth(prepared.apiOrigin, "user") +): void { + try { + installTransportV2Credentials( + prepared.apiOrigin, + "user", + prepared.accessToken, + prepared.refreshToken, + expected + ); + setTransportV2CacheRoot(prepared.apiOrigin, prepared.cacheNamespaceRoot); + } finally { + prepared.cacheNamespaceRoot.fill(0); + } +} + +export async function importTransportV2AuthBundle( + bundle: string, + expectedApiUrl: string +): Promise { + const expected = snapshotTransportV2Auth(expectedApiUrl, "user"); + commitTransportV2AuthBundleImport( + prepareTransportV2AuthBundleImport(bundle, expectedApiUrl), + expected + ); +} diff --git a/sdk/src/lib/transportV2/client.ts b/sdk/src/lib/transportV2/client.ts new file mode 100644 index 000000000..25dee19f9 --- /dev/null +++ b/sdk/src/lib/transportV2/client.ts @@ -0,0 +1,1493 @@ +import { + decodeUtf8, + encodeCanonicalBase64, + encodeUtf8, + MIN_ENCRYPTED_RECORD_BYTES, + parseStrictJson, + requireExactObject +} from "./encoding"; +import type { + LogicalMethod, + ResponseMode, + TransportV2Credential, + TransportV2Header, + TransportV2LogicalRequest, + TransportV2StreamRecord +} from "./envelope"; +import { TRANSPORT_V2_LIMITS } from "./envelope"; +import { TransportV2Handshake } from "./handshake"; +import { + TransportV2Session, + TransportV2SessionUnavailableError, + type PreparedTransportV2Request, + type SerializedTransportV2SessionState +} from "./session"; +import { isLocalDevelopmentApiUrl, verifyAttestationDocument } from "../attestation"; +import { + requireTrustedPcr0, + serializePcrConfig, + snapshotPcrConfig, + validatePcr0Hash +} from "../pcr"; +import type { PcrConfig } from "../pcr"; +import { + canonicalizeTransportV2ApiUrl, + clearLegacyTransportV1Credentials, + clearTransportV2CacheRoot, + clearTransportV2Credentials, + clearTransportV2CredentialsIfCurrent, + getOrCreateTransportV2CacheRoot, + installTransportV2Credentials, + isTransportV2AuthSnapshotCurrent, + readTransportV2Credentials, + snapshotTransportV2Auth, + type StoredTransportV2Credentials, + type TransportV2AuthSnapshot, + TransportV2AuthorityChangedError, + type TransportV2PrincipalKind +} from "./auth"; + +const MAX_HANDSHAKE_RESPONSE_BYTES = 4 * 1024; +const MAX_ATTESTATION_RESPONSE_BYTES = 2 * 1024 * 1024; +const MAX_OUTER_RESPONSE_BYTES = + TRANSPORT_V2_LIMITS.responseEnvelopeBytes + MIN_ENCRYPTED_RECORD_BYTES; +const OAUTH_CONTINUATION_PREFIX = "opensecret:transport-v2:oauth:v1:"; +const AUTH_RENEWAL_SKEW_SECONDS = 30; + +export type TransportV2Authority = + | { kind: "anonymous"; purpose: "public" | "user" | "platform" } + | { kind: "user"; principalId: string; generation: number } + | { kind: "platform"; principalId: string; generation: number } + | { kind: "api_key"; value: string }; + +export interface TransportV2FetchInput { + apiUrl: string; + pcrConfig?: PcrConfig; + url: string; + method: LogicalMethod; + headers?: HeadersInit; + body: Uint8Array | null; + responseMode: ResponseMode; + authority: TransportV2Authority; + signal?: AbortSignal | null; +} + +export interface TransportV2SessionInfo { + protocolVersion: 2; + sessionId: string; + expiresAtUnixSeconds: number; + authority: "anonymous" | "user" | "platform" | "api_key"; +} + +interface OAuthContinuation { + version: 2; + api_origin: string; + pcr_policy: string; + provider: "github" | "google" | "apple"; + state: string; + session: SerializedTransportV2SessionState; +} + +interface ManagedSession { + session: TransportV2Session; + authority: TransportV2SessionInfo["authority"]; + principalId?: string; + authGeneration?: number; +} + +interface SendResult { + response: Response; + session: ManagedSession; +} + +export interface TransportV2ClientDependencies { + fetch: typeof globalThis.fetch; + crypto: Crypto; + verifyAttestationDocument: typeof verifyAttestationDocument; + validatePcr0Hash: typeof validatePcr0Hash; + /** @internal Deterministic capacity hook for session-manager tests. */ + sessionResponseRecordLimit?: number; +} + +const defaultDependencies: TransportV2ClientDependencies = { + fetch: (...args) => globalThis.fetch(...args), + crypto: globalThis.crypto, + verifyAttestationDocument, + validatePcr0Hash +}; + +function sessionStorageOrUndefined(): Storage | undefined { + try { + return globalThis.sessionStorage; + } catch { + return undefined; + } +} + +function safeSessionStorageGet(key: string): string | null { + try { + return sessionStorageOrUndefined()?.getItem(key) ?? null; + } catch { + return null; + } +} + +function safeSessionStorageSet(key: string, value: string): void { + try { + const storage = sessionStorageOrUndefined(); + if (!storage) { + throw new Error("same-tab session storage is unavailable"); + } + storage.setItem(key, value); + } catch { + throw new Error("OAuth requires same-tab session storage for its attested continuation."); + } +} + +function safeSessionStorageRemove(key: string): void { + try { + sessionStorageOrUndefined()?.removeItem(key); + } catch { + // The in-memory session will still be disposed; unavailable browser + // storage cannot be used to resume a continuation. + } +} + +function oauthContinuationKey( + apiOrigin: string, + pcrPolicy: string, + provider: OAuthContinuation["provider"] +): string { + const scope = encodeCanonicalBase64(encodeUtf8(`${apiOrigin}\n${pcrPolicy}\n${provider}`)) + .replace(/=+$/u, "") + .replace(/\+/gu, "-") + .replace(/\//gu, "_"); + return `${OAUTH_CONTINUATION_PREFIX}${scope}`; +} + +function parseOAuthContinuation(raw: string): OAuthContinuation { + const value = requireExactObject( + parseStrictJson(raw), + ["version", "api_origin", "pcr_policy", "provider", "state", "session"], + "Transport v2 OAuth continuation" + ); + if ( + value.version !== 2 || + typeof value.api_origin !== "string" || + typeof value.pcr_policy !== "string" || + !(["github", "google", "apple"] as const).includes( + value.provider as OAuthContinuation["provider"] + ) || + typeof value.state !== "string" || + value.state.length === 0 || + typeof value.session !== "object" || + value.session === null + ) { + throw new Error("Transport v2 OAuth continuation is invalid."); + } + const session = requireExactObject( + value.session, + [ + "version", + "sessionId", + "expiresAtUnixSeconds", + "requestKeyBase64", + "responseKeyBase64", + "requestRecords", + "responseRecords" + ], + "Transport v2 OAuth session" + ); + return { ...value, session } as unknown as OAuthContinuation; +} + +function exactResponseHeaders(headers: readonly TransportV2Header[]): Headers { + const result = new Headers(); + for (const header of headers) { + let value = ""; + for (const byte of header.value) value += String.fromCharCode(byte); + result.append(header.name, value); + } + return result; +} + +function logicalResponse(response: { + status: number; + headers: readonly TransportV2Header[]; + body: Uint8Array | null; +}): Response { + return new Response(response.body, { + status: response.status, + headers: exactResponseHeaders(response.headers) + }); +} + +async function readBoundedBytes(response: Response, limit: number, description: string) { + const contentLength = response.headers.get("content-length"); + if (contentLength && /^\d+$/u.test(contentLength) && Number(contentLength) > limit) { + await response.body?.cancel().catch(() => {}); + throw new Error(`${description} exceeds its size limit.`); + } + if (!response.body) return new Uint8Array(0); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.length; + if (!Number.isSafeInteger(total) || total > limit) { + next.value.fill(0); + throw new Error(`${description} exceeds its size limit.`); + } + chunks.push(next.value); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + chunk.fill(0); + } + chunks.length = 0; + return bytes; + } catch (error) { + await reader.cancel().catch(() => {}); + for (const chunk of chunks) chunk.fill(0); + throw error; + } +} + +async function readBoundedText(response: Response, limit: number, description: string) { + const bytes = await readBoundedBytes(response, limit, description); + try { + return decodeUtf8(bytes); + } finally { + bytes.fill(0); + } +} + +function hasExactContentType(response: Response, expected: string): boolean { + return response.headers.get("content-type")?.trim().toLowerCase() === expected; +} + +function requestParts(apiOrigin: string, requestUrl: string) { + let request: URL; + const base = new URL(apiOrigin); + try { + request = new URL(requestUrl); + } catch { + throw new Error("Transport v2 request URL is invalid."); + } + if (request.origin !== base.origin || request.username || request.password || request.hash) { + throw new Error("Transport v2 request escaped its attested API origin."); + } + + const basePath = base.pathname === "/" ? "" : base.pathname.replace(/\/+$/u, ""); + if (basePath && request.pathname !== basePath && !request.pathname.startsWith(`${basePath}/`)) { + throw new Error("Transport v2 request escaped its configured API base path."); + } + const path = basePath ? request.pathname.slice(basePath.length) || "/" : request.pathname; + return { path, query: request.search ? request.search.slice(1) : null }; +} + +function logicalHeaders(headers?: HeadersInit): TransportV2Header[] { + if (!headers) return []; + const logical: TransportV2Header[] = []; + new Headers(headers).forEach((value, name) => { + logical.push({ name: name.toLowerCase(), value: encodeUtf8(value) }); + }); + return logical; +} + +function strictJsonObject(bytes: Uint8Array | null): Record { + if (!bytes) throw new Error("Transport v2 operation requires a JSON body."); + const parsed = parseStrictJson(decodeUtf8(bytes)); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("Transport v2 operation requires a JSON object."); + } + return parsed as Record; +} + +function oauthProvider(path: string): OAuthContinuation["provider"] | null { + if (path.startsWith("/auth/github")) return "github"; + if (path.startsWith("/auth/google")) return "google"; + if (path.startsWith("/auth/apple")) return "apple"; + return null; +} + +function isOAuthInitiation(path: string): boolean { + return path === "/auth/github" || path === "/auth/google" || path === "/auth/apple"; +} + +function isOAuthCallback(path: string): boolean { + return ( + path === "/auth/github/callback" || + path === "/auth/google/callback" || + path === "/auth/apple/callback" + ); +} + +function isUserBinding(path: string): boolean { + return ( + path === "/login" || + path === "/register" || + isOAuthCallback(path) || + path === "/auth/apple/native" + ); +} + +function isPlatformBinding(path: string): boolean { + return path === "/platform/login" || path === "/platform/register"; +} + +function isTerminalUserOperation(path: string): boolean { + return ( + path === "/logout" || + path === "/protected/change_password" || + path === "/protected/delete-account/confirm" + ); +} + +function isTerminalPlatformOperation(path: string): boolean { + return path === "/platform/logout" || path === "/platform/change-password"; +} + +async function apiKeyHash(apiKey: string, crypto: Crypto): Promise { + const bytes = encodeUtf8(apiKey); + try { + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); + try { + return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); + } finally { + digest.fill(0); + } + } finally { + bytes.fill(0); + } +} + +function authSnapshotForAuthority( + apiOrigin: string, + authority: Exclude +): TransportV2AuthSnapshot | null { + if (authority.kind === "user" || authority.kind === "platform") { + return { + apiOrigin, + kind: authority.kind, + principalId: authority.principalId, + generation: authority.generation + }; + } + if (authority.purpose === "user" || authority.purpose === "platform") { + return snapshotTransportV2Auth(apiOrigin, authority.purpose); + } + return null; +} + +function credentialsSnapshot(credentials: StoredTransportV2Credentials): TransportV2AuthSnapshot { + return { + apiOrigin: credentials.apiOrigin, + kind: credentials.kind, + principalId: credentials.principalId, + generation: credentials.generation + }; +} + +function isAuthenticatedSessionExhausted(status: number, body: Uint8Array | null): boolean { + if (status !== 503 || body === null) return false; + try { + const value = requireExactObject( + parseStrictJson(decodeUtf8(body)), + ["error"], + "Transport v2 session exhaustion response" + ); + const error = requireExactObject( + value.error, + ["code", "message"], + "Transport v2 session exhaustion error" + ); + return ( + error.code === "session_exhausted" && + typeof error.message === "string" && + error.message.length > 0 + ); + } catch { + return false; + } +} + +function credentialSessionLabel(credentials: StoredTransportV2Credentials): string { + return `${credentials.kind}:${credentials.principalId}:${credentials.generation}`; +} + +function bindingKind(path: string): TransportV2PrincipalKind | null { + if (isUserBinding(path)) return "user"; + if (isPlatformBinding(path)) return "platform"; + return null; +} + +export class TransportV2Client { + #dependencies: TransportV2ClientDependencies; + #sessions = new Map(); + #establishing = new Map>(); + #refreshing = new Map>(); + #apiKeyBindings = new Map>(); + #bindingTransitions = new Set(); + #apiKeyGenerations = new Map(); + #refreshSuccessors = new Map(); + + constructor(dependencies: TransportV2ClientDependencies = defaultDependencies) { + this.#dependencies = dependencies; + } + + async fetch(input: TransportV2FetchInput): Promise { + input.signal?.throwIfAborted(); + const apiOrigin = canonicalizeTransportV2ApiUrl(input.apiUrl); + const pcrConfig = snapshotPcrConfig(input.pcrConfig); + const pcrPolicy = serializePcrConfig(pcrConfig); + const parts = requestParts(apiOrigin, input.url); + const request: TransportV2LogicalRequest = { + method: input.method, + path: parts.path, + query: parts.query, + headers: logicalHeaders(input.headers), + body: input.body + }; + + if (input.authority.kind === "api_key") { + return this.#fetchWithApiKey(apiOrigin, pcrConfig, pcrPolicy, request, input); + } + + const authSnapshot = authSnapshotForAuthority(apiOrigin, input.authority); + if (authSnapshot && !isTransportV2AuthSnapshotCurrent(authSnapshot)) { + throw new TransportV2AuthorityChangedError(); + } + + const transitionKind = bindingKind(parts.path); + const transitionSlot = transitionKind + ? this.#slot(apiOrigin, pcrPolicy, `binding:${transitionKind}`) + : null; + if (transitionSlot) { + if (this.#bindingTransitions.has(transitionSlot)) { + throw new Error(`A transport v2 ${transitionKind} authentication is already in progress.`); + } + this.#bindingTransitions.add(transitionSlot); + } + + try { + if (parts.path === "/refresh") { + return this.refresh(apiOrigin, "user", pcrConfig); + } + if (parts.path === "/platform/refresh") { + return this.refresh(apiOrigin, "platform", pcrConfig); + } + + let managed: ManagedSession; + if (isOAuthCallback(parts.path)) { + const provider = oauthProvider(parts.path)!; + const state = strictJsonObject(input.body).state; + if (typeof state !== "string" || state.length === 0) { + throw new Error("Transport v2 OAuth callback is missing its state."); + } + managed = this.#consumeOAuthContinuation(apiOrigin, pcrPolicy, provider, state); + } else { + managed = await this.#sessionForAuthority(apiOrigin, pcrConfig, pcrPolicy, input.authority); + } + + const cacheRoot = + isUserBinding(parts.path) && !isOAuthInitiation(parts.path) + ? getOrCreateTransportV2CacheRoot(apiOrigin, this.#dependencies.crypto) + : null; + try { + // Session establishment and request-body capture can both yield. Fence + // the exact identity again immediately before the one network send. + if (authSnapshot && !this.#isExpectedOrManagedAuthCurrent(authSnapshot, managed)) { + throw new TransportV2AuthorityChangedError(); + } + const result = await this.#send( + managed, + apiOrigin, + pcrPolicy, + request, + input.responseMode, + null, + cacheRoot, + input.signal + ); + if (authSnapshot && !this.#isExpectedOrManagedAuthCurrent(authSnapshot, managed)) { + this.#retireManagedSession(apiOrigin, pcrPolicy, managed); + throw new TransportV2AuthorityChangedError(); + } + if ( + result.response.status === 401 && + (managed.authority === "user" || managed.authority === "platform") + ) { + this.#retireManagedSession(apiOrigin, pcrPolicy, managed); + } + await this.#afterSuccessfulOperation( + result, + apiOrigin, + pcrPolicy, + parts.path, + authSnapshot + ); + return result.response; + } catch (error) { + if (isUserBinding(parts.path) || isPlatformBinding(parts.path)) { + this.#retireManagedSession(apiOrigin, pcrPolicy, managed); + } + if (isTerminalUserOperation(parts.path) || isTerminalPlatformOperation(parts.path)) { + this.#retireManagedSession(apiOrigin, pcrPolicy, managed); + } + throw error; + } finally { + cacheRoot?.fill(0); + } + } finally { + if (transitionSlot) this.#bindingTransitions.delete(transitionSlot); + } + } + + async refresh( + apiUrl: string, + kind: TransportV2PrincipalKind, + pcrConfig?: PcrConfig + ): Promise { + const apiOrigin = canonicalizeTransportV2ApiUrl(apiUrl); + const policy = snapshotPcrConfig(pcrConfig); + const pcrPolicy = serializePcrConfig(policy); + const credentials = readTransportV2Credentials(apiOrigin, kind); + if (!credentials) { + clearLegacyTransportV1Credentials(); + throw new Error("A fresh transport v2 sign-in is required."); + } + const renewalSlot = this.#slot( + apiOrigin, + pcrPolicy, + `resume:${kind}:${credentials.principalId}:${credentials.generation}` + ); + let renewal = this.#refreshing.get(renewalSlot); + if (!renewal) { + renewal = this.#performRefresh(apiOrigin, kind, policy, pcrPolicy, credentials); + this.#refreshing.set(renewalSlot, renewal); + } + try { + return (await renewal).clone(); + } finally { + if (this.#refreshing.get(renewalSlot) === renewal) { + this.#refreshing.delete(renewalSlot); + } + } + } + + async #performRefresh( + apiOrigin: string, + kind: TransportV2PrincipalKind, + policy: PcrConfig, + pcrPolicy: string, + credentials: StoredTransportV2Credentials + ): Promise { + const expected = credentialsSnapshot(credentials); + if (!isTransportV2AuthSnapshotCurrent(expected)) { + throw new TransportV2AuthorityChangedError(); + } + this.#retireCredentialSessions(apiOrigin, pcrPolicy, expected); + const managed = await this.#establish(apiOrigin, policy, pcrPolicy, `resume:${kind}`); + let root: Uint8Array | null | undefined; + let credentialBytes: Uint8Array | undefined; + try { + root = + kind === "user" + ? getOrCreateTransportV2CacheRoot(apiOrigin, this.#dependencies.crypto) + : null; + const path = kind === "user" ? "/refresh" : "/platform/refresh"; + credentialBytes = encodeUtf8(credentials.refreshToken); + if (!isTransportV2AuthSnapshotCurrent(expected)) { + throw new TransportV2AuthorityChangedError(); + } + const response = await this.#send( + managed, + apiOrigin, + pcrPolicy, + { method: "POST", path, query: null, headers: [], body: null }, + "unary", + { kind: "resumption", value: credentialBytes }, + root, + undefined + ); + if (!response.response.ok) { + managed.session.dispose(); + if (!clearTransportV2CredentialsIfCurrent(expected)) { + throw new TransportV2AuthorityChangedError(); + } + return response.response; + } + const installed = await this.#installBindingResponse( + response.response, + apiOrigin, + kind, + expected + ); + this.#refreshSuccessors.set(this.#authSnapshotKey(expected), credentialsSnapshot(installed)); + response.session.authority = kind; + response.session.principalId = installed.principalId; + response.session.authGeneration = installed.generation; + this.#sessions.set( + this.#slot(apiOrigin, pcrPolicy, credentialSessionLabel(installed)), + response.session + ); + return response.response; + } catch (error) { + managed.session.dispose(); + throw error; + } finally { + // The descriptor itself remains persisted, but no temporary plaintext + // credential bytes survive the encrypted binding operation. + credentialBytes?.fill(0); + root?.fill(0); + } + } + + async sessionInfo( + apiUrl: string, + pcrConfig: PcrConfig | undefined, + authority: TransportV2Authority = { kind: "anonymous", purpose: "public" } + ): Promise { + const apiOrigin = canonicalizeTransportV2ApiUrl(apiUrl); + const policy = snapshotPcrConfig(pcrConfig); + const pcrPolicy = serializePcrConfig(policy); + if (authority.kind === "api_key") { + throw new Error("API-key readiness is established by its first encrypted operation."); + } + const managed = await this.#sessionForAuthority(apiOrigin, policy, pcrPolicy, authority); + return { + protocolVersion: 2, + sessionId: managed.session.sessionId, + expiresAtUnixSeconds: managed.session.expiresAtUnixSeconds, + authority: managed.authority + }; + } + + clear( + apiUrl: string, + kind?: TransportV2PrincipalKind, + purgeCacheRoot = false, + expected?: TransportV2AuthSnapshot + ): boolean { + const apiOrigin = canonicalizeTransportV2ApiUrl(apiUrl); + if (!kind) { + for (const managed of this.#sessions.values()) managed.session.dispose(); + this.#sessions.clear(); + clearTransportV2Credentials(apiOrigin); + this.#clearOAuthContinuations(apiOrigin); + if (purgeCacheRoot) clearTransportV2CacheRoot(apiOrigin); + return true; + } + + let target = expected ?? snapshotTransportV2Auth(apiOrigin, kind); + if (target.apiOrigin !== apiOrigin || target.kind !== kind) return false; + if (!isTransportV2AuthSnapshotCurrent(target)) { + const successor = this.#refreshSuccessors.get(this.#authSnapshotKey(target)); + if (!successor || !isTransportV2AuthSnapshotCurrent(successor)) return false; + target = successor; + } + if (!clearTransportV2CredentialsIfCurrent(target)) { + return false; + } + const anonymousLabel = kind === "user" ? "anonymous:user" : "anonymous:platform"; + for (const [key, managed] of this.#sessions) { + if (!key.startsWith(`${apiOrigin}\n`)) continue; + if ( + !( + managed.authority === kind && + managed.principalId === target.principalId && + managed.authGeneration === target.generation + ) && + (managed.authority !== "anonymous" || !key.endsWith(`\n${anonymousLabel}`)) + ) { + continue; + } + managed.session.dispose(); + this.#sessions.delete(key); + } + if (kind === "user") this.#clearOAuthContinuations(apiOrigin); + if (purgeCacheRoot) clearTransportV2CacheRoot(apiOrigin); + return true; + } + + retireAuthenticationState(apiUrl: string, kind: TransportV2PrincipalKind): void { + const apiOrigin = canonicalizeTransportV2ApiUrl(apiUrl); + const anonymousLabel = kind === "user" ? "anonymous:user" : "anonymous:platform"; + for (const [key, managed] of this.#sessions) { + if (!key.startsWith(`${apiOrigin}\n`)) continue; + if ( + managed.authority !== kind && + (managed.authority !== "anonymous" || !key.endsWith(`\n${anonymousLabel}`)) + ) { + continue; + } + managed.session.dispose(); + this.#sessions.delete(key); + } + if (kind === "user") this.#clearOAuthContinuations(apiOrigin); + } + + async retireApiKey( + apiUrl: string, + pcrConfig: PcrConfig | undefined, + apiKey: string + ): Promise { + const apiOrigin = canonicalizeTransportV2ApiUrl(apiUrl); + const pcrPolicy = serializePcrConfig(snapshotPcrConfig(pcrConfig)); + const hash = await apiKeyHash(apiKey, this.#dependencies.crypto); + const slot = this.#slot(apiOrigin, pcrPolicy, `api-key:${hash}`); + this.#apiKeyGenerations.set(slot, (this.#apiKeyGenerations.get(slot) ?? 0) + 1); + this.#sessions.get(slot)?.session.dispose(); + this.#sessions.delete(slot); + } + + async #sessionForAuthority( + apiOrigin: string, + pcrConfig: PcrConfig, + pcrPolicy: string, + authority: Exclude + ): Promise { + if (authority.kind === "anonymous") { + const label = `anonymous:${authority.purpose}`; + return this.#getOrEstablish(apiOrigin, pcrConfig, pcrPolicy, label); + } + + const credentials = readTransportV2Credentials(apiOrigin, authority.kind); + if (!credentials) { + clearLegacyTransportV1Credentials(); + throw new Error("A fresh transport v2 sign-in is required."); + } + if ( + credentials.principalId !== authority.principalId || + credentials.generation !== authority.generation + ) { + throw new TransportV2AuthorityChangedError(); + } + const label = credentialSessionLabel(credentials); + const key = this.#slot(apiOrigin, pcrPolicy, label); + const current = this.#sessions.get(key); + const now = Math.floor(Date.now() / 1000); + if ( + current && + !current.session.isDisposed && + current.session.expiresAtUnixSeconds > now && + current.principalId === credentials.principalId && + current.authGeneration === credentials.generation && + credentials.accessExpiresAtUnixSeconds > now + AUTH_RENEWAL_SKEW_SECONDS + ) { + return current; + } + current?.session.dispose(); + this.#sessions.delete(key); + + const inFlight = this.#establishing.get(key); + if (inFlight) return inFlight; + const renewal = (async () => { + const response = await this.refresh(apiOrigin, authority.kind, pcrConfig); + if (!response.ok) { + throw Object.assign(new Error("Transport v2 session resumption was rejected."), { + status: response.status, + headers: new Headers(response.headers) + }); + } + const refreshed = readTransportV2Credentials(apiOrigin, authority.kind); + if (!refreshed) throw new Error("Transport v2 session resumption returned no credentials."); + if (refreshed.principalId !== credentials.principalId) { + throw new TransportV2AuthorityChangedError(); + } + if (!isTransportV2AuthSnapshotCurrent(credentialsSnapshot(refreshed))) { + throw new TransportV2AuthorityChangedError(); + } + const renewed = this.#sessions.get( + this.#slot(apiOrigin, pcrPolicy, credentialSessionLabel(refreshed)) + ); + if (!renewed) throw new Error("Transport v2 session resumption did not bind a session."); + return renewed; + })(); + this.#establishing.set(key, renewal); + try { + return await renewal; + } finally { + if (this.#establishing.get(key) === renewal) this.#establishing.delete(key); + } + } + + async #fetchWithApiKey( + apiOrigin: string, + pcrConfig: PcrConfig, + pcrPolicy: string, + request: TransportV2LogicalRequest, + input: TransportV2FetchInput + ): Promise { + if (input.authority.kind !== "api_key") { + throw new Error("Transport v2 API-key authority is invalid."); + } + const rawApiKey = input.authority.value; + const hash = await apiKeyHash(rawApiKey, this.#dependencies.crypto); + const label = `api-key:${hash}`; + const key = this.#slot(apiOrigin, pcrPolicy, label); + const generation = this.#apiKeyGenerations.get(key) ?? 0; + const current = this.#sessions.get(key); + const now = Math.floor(Date.now() / 1000); + if (current && !current.session.isDisposed && current.session.expiresAtUnixSeconds > now) { + const result = await this.#send( + current, + apiOrigin, + pcrPolicy, + request, + input.responseMode, + null, + null, + input.signal + ); + if (result.response.status === 401) { + this.#retireManagedSession(apiOrigin, pcrPolicy, current); + } + return result.response; + } + current?.session.dispose(); + this.#sessions.delete(key); + + const binding = this.#apiKeyBindings.get(key); + if (binding) { + await binding; + if ((this.#apiKeyGenerations.get(key) ?? 0) !== generation) { + throw new TransportV2AuthorityChangedError(); + } + return this.#fetchWithApiKey(apiOrigin, pcrConfig, pcrPolicy, request, input); + } + + let resolveBinding!: () => void; + const gate = new Promise((resolve) => { + resolveBinding = resolve; + }); + this.#apiKeyBindings.set(key, gate); + let managed: ManagedSession | undefined; + let root: Uint8Array | undefined; + let credentialBytes: Uint8Array | undefined; + try { + managed = await this.#establish(apiOrigin, pcrConfig, pcrPolicy, label); + if ((this.#apiKeyGenerations.get(key) ?? 0) !== generation) { + throw new TransportV2AuthorityChangedError(); + } + root = getOrCreateTransportV2CacheRoot(apiOrigin, this.#dependencies.crypto); + credentialBytes = encodeUtf8(rawApiKey); + const credential: TransportV2Credential = { + kind: "api_key", + value: credentialBytes + }; + const result = await this.#send( + managed, + apiOrigin, + pcrPolicy, + request, + input.responseMode, + credential, + root, + input.signal + ); + if (result.response.ok) { + if ((this.#apiKeyGenerations.get(key) ?? 0) !== generation) { + throw new TransportV2AuthorityChangedError(); + } + managed.authority = "api_key"; + this.#sessions.set(key, managed); + resolveBinding(); + } else { + managed.session.dispose(); + resolveBinding(); + } + return result.response; + } catch (error) { + managed?.session.dispose(); + resolveBinding(); + throw error; + } finally { + credentialBytes?.fill(0); + root?.fill(0); + if (this.#apiKeyBindings.get(key) === gate) this.#apiKeyBindings.delete(key); + } + } + + async #getOrEstablish( + apiOrigin: string, + pcrConfig: PcrConfig, + pcrPolicy: string, + label: string + ): Promise { + const key = this.#slot(apiOrigin, pcrPolicy, label); + const current = this.#sessions.get(key); + const now = Math.floor(Date.now() / 1000); + if (current && !current.session.isDisposed && current.session.expiresAtUnixSeconds > now) { + return current; + } + current?.session.dispose(); + this.#sessions.delete(key); + const inFlight = this.#establishing.get(key); + if (inFlight) return inFlight; + const establishing = this.#establish(apiOrigin, pcrConfig, pcrPolicy, label); + this.#establishing.set(key, establishing); + try { + const managed = await establishing; + this.#sessions.set(key, managed); + return managed; + } finally { + if (this.#establishing.get(key) === establishing) this.#establishing.delete(key); + } + } + + async #establish( + apiOrigin: string, + pcrConfig: PcrConfig, + _pcrPolicy: string, + _label: string + ): Promise { + const nonce = this.#dependencies.crypto.randomUUID(); + const handshake = new TransportV2Handshake(nonce); + try { + const attestationResponse = await this.#dependencies.fetch( + `${apiOrigin}/v2/attestation/${encodeURIComponent(nonce)}`, + { method: "GET", credentials: "omit", cache: "no-store", redirect: "error" } + ); + if (!attestationResponse.ok) { + throw new Error( + `Transport v2 attestation failed with status ${attestationResponse.status}.` + ); + } + const attestationBody = await readBoundedText( + attestationResponse, + MAX_ATTESTATION_RESPONSE_BYTES, + "Transport v2 attestation response" + ); + const parsed = parseStrictJson(attestationBody); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) || + typeof (parsed as Record).attestation_document !== "string" + ) { + throw new Error("Transport v2 attestation response is invalid."); + } + const document = await this.#dependencies.verifyAttestationDocument( + (parsed as Record).attestation_document, + nonce, + apiOrigin + ); + if (!document.public_key || document.public_key.length !== 32) { + throw new Error("Transport v2 attestation document has no valid public key."); + } + if (!isLocalDevelopmentApiUrl(apiOrigin)) { + await requireTrustedPcr0(document.pcrs, pcrConfig, this.#dependencies.validatePcr0Hash); + } + + const keyRequest = handshake.keyExchangeRequest(); + const keyResponse = await this.#dependencies.fetch(`${apiOrigin}${keyRequest.path}`, { + method: keyRequest.method, + headers: keyRequest.headers, + body: keyRequest.body, + credentials: "omit", + redirect: "error" + }); + if (!keyResponse.ok) { + throw new Error(`Transport v2 key exchange failed with status ${keyResponse.status}.`); + } + const keyBody = await readBoundedText( + keyResponse, + MAX_HANDSHAKE_RESPONSE_BYTES, + "Transport v2 key exchange response" + ); + const session = await handshake.complete( + new Uint8Array(document.public_key), + keyBody, + this.#dependencies.crypto.subtle, + this.#dependencies.sessionResponseRecordLimit + ); + return { session, authority: "anonymous" }; + } catch (error) { + handshake.dispose(); + throw error; + } + } + + async #send( + managed: ManagedSession, + apiOrigin: string, + pcrPolicy: string, + request: TransportV2LogicalRequest, + responseMode: ResponseMode, + credential: TransportV2Credential | null, + cacheNamespaceRoot: Uint8Array | null, + signal?: AbortSignal | null + ): Promise { + signal?.throwIfAborted(); + let prepared: PreparedTransportV2Request; + try { + prepared = managed.session.prepareRequest({ + responseMode, + credential, + cacheNamespaceRoot, + request + }); + } catch (error) { + if (error instanceof TransportV2SessionUnavailableError) { + this.#retireManagedSession(apiOrigin, pcrPolicy, managed); + } + throw error; + } + const outer = prepared.takeHttpRequest(); + let response: Response; + try { + response = await this.#dependencies.fetch(`${apiOrigin}${outer.path}`, { + method: outer.method, + headers: outer.headers, + body: outer.body, + credentials: "omit", + redirect: "error", + signal + }); + } catch (error) { + prepared.dispose(); + this.#retireManagedSession(apiOrigin, pcrPolicy, managed); + throw error; + } finally { + outer.body.fill(0); + } + + if (responseMode === "unary") { + if (response.status !== 200 || !hasExactContentType(response, "application/octet-stream")) { + prepared.dispose(); + this.#retireManagedSession(apiOrigin, pcrPolicy, managed); + throw new Error("Transport v2 returned an unauthenticated outer response."); + } + try { + const body = await readBoundedBytes( + response, + MAX_OUTER_RESPONSE_BYTES, + "Transport v2 outer response" + ); + const logical = prepared.decryptUnaryResponse(body); + if (isAuthenticatedSessionExhausted(logical.status, logical.body)) { + this.#retireManagedSession(apiOrigin, pcrPolicy, managed); + } + return { response: logicalResponse(logical), session: managed }; + } catch (error) { + prepared.dispose(); + this.#retireManagedSession(apiOrigin, pcrPolicy, managed); + throw error; + } + } + return { + response: await this.#decodeStreamResponse(prepared, response, () => + this.#retireManagedSession(apiOrigin, pcrPolicy, managed) + ), + session: managed + }; + } + + async #decodeStreamResponse( + prepared: PreparedTransportV2Request, + response: Response, + retireSession: () => void + ): Promise { + if (!hasExactContentType(response, "text/event-stream")) { + if (response.status !== 200 || !hasExactContentType(response, "application/octet-stream")) { + prepared.dispose(); + retireSession(); + throw new Error("Transport v2 returned an unauthenticated outer stream response."); + } + try { + const body = await readBoundedBytes( + response, + MAX_OUTER_RESPONSE_BYTES, + "Transport v2 outer response" + ); + const logical = prepared.decryptPreStartUnaryError(body); + if (isAuthenticatedSessionExhausted(logical.status, logical.body)) retireSession(); + return logicalResponse(logical); + } catch (error) { + prepared.dispose(); + retireSession(); + throw error; + } + } + if (response.status !== 200 || !response.body) { + prepared.dispose(); + retireSession(); + throw new Error("Transport v2 stream response is invalid."); + } + + const decoder = prepared.createStreamDecoder(); + const reader = response.body.getReader(); + let queued: TransportV2StreamRecord[] = []; + let start: Extract | undefined; + try { + while (!start) { + const next = await reader.read(); + if (next.done) { + decoder.finish(); + throw new Error("Transport v2 stream ended before Start."); + } + queued.push(...decoder.push(next.value)); + const candidate = queued.shift(); + if (candidate?.kind === "start") start = candidate; + else if (candidate) throw new Error("Transport v2 stream did not begin with Start."); + } + } catch (error) { + decoder.dispose(); + await reader.cancel().catch(() => {}); + retireSession(); + throw error; + } + + const body = new ReadableStream({ + async pull(controller) { + try { + while (true) { + const record = queued.shift(); + if (record) { + if (record.kind === "chunk") { + controller.enqueue(record.body); + return; + } + if (record.kind === "end") { + // End authenticates logical finality, but the outer HTTP body + // must also end there. Keep reading until EOF so a forwarding + // host cannot hide bytes in a later carrier chunk after the + // authenticated terminal record. + while (true) { + const trailing = await reader.read(); + if (trailing.done) break; + decoder.push(trailing.value); + } + decoder.finish(); + controller.close(); + return; + } + if (record.kind === "error") { + const exhausted = isAuthenticatedSessionExhausted(record.status, record.body); + const message = decodeUtf8(record.body); + record.body.fill(0); + decoder.finish(); + if (exhausted) retireSession(); + controller.error(Object.assign(new Error(message), { status: record.status })); + return; + } + throw new Error("Transport v2 stream contains a duplicate Start."); + } + const next = await reader.read(); + if (next.done) { + decoder.finish(); + controller.close(); + return; + } + queued.push(...decoder.push(next.value)); + } + } catch (error) { + decoder.dispose(); + retireSession(); + controller.error(error); + } + }, + async cancel() { + decoder.dispose(); + await reader.cancel(); + } + }); + return new Response(body, { + status: start.status, + headers: exactResponseHeaders(start.headers) + }); + } + + async #afterSuccessfulOperation( + result: SendResult, + apiOrigin: string, + pcrPolicy: string, + path: string, + expected: TransportV2AuthSnapshot | null + ): Promise { + if (!result.response.ok) return; + if (isOAuthInitiation(path)) { + if (!expected || !isTransportV2AuthSnapshotCurrent(expected)) { + throw new TransportV2AuthorityChangedError(); + } + const provider = oauthProvider(path)!; + const value = (await result.response.clone().json()) as Record; + const state = value.state; + if (typeof state !== "string" || state.length === 0) { + throw new Error("Transport v2 OAuth initiation returned no state."); + } + this.#persistOAuthContinuation(apiOrigin, pcrPolicy, provider, state, result.session.session); + return; + } + if (isUserBinding(path)) { + if (!expected || expected.kind !== "user") throw new TransportV2AuthorityChangedError(); + const credentials = await this.#installBindingResponse( + result.response, + apiOrigin, + "user", + expected + ); + this.#removeManagedSessionReference( + this.#slot(apiOrigin, pcrPolicy, "anonymous:user"), + result.session + ); + this.#retireCredentialSessions(apiOrigin, pcrPolicy, expected); + result.session.authority = "user"; + result.session.principalId = credentials.principalId; + result.session.authGeneration = credentials.generation; + this.#sessions.set( + this.#slot(apiOrigin, pcrPolicy, credentialSessionLabel(credentials)), + result.session + ); + return; + } + if (isPlatformBinding(path)) { + if (!expected || expected.kind !== "platform") { + throw new TransportV2AuthorityChangedError(); + } + const credentials = await this.#installBindingResponse( + result.response, + apiOrigin, + "platform", + expected + ); + this.#removeManagedSessionReference( + this.#slot(apiOrigin, pcrPolicy, "anonymous:platform"), + result.session + ); + this.#retireCredentialSessions(apiOrigin, pcrPolicy, expected); + result.session.authority = "platform"; + result.session.principalId = credentials.principalId; + result.session.authGeneration = credentials.generation; + this.#sessions.set( + this.#slot(apiOrigin, pcrPolicy, credentialSessionLabel(credentials)), + result.session + ); + return; + } + + if (path === "/protected/change_password" || path === "/platform/change-password") { + const kind = path.startsWith("/platform/") ? "platform" : "user"; + if (!expected || expected.kind !== kind) throw new TransportV2AuthorityChangedError(); + const current = this.#currentAuthSnapshotForManaged(expected, result.session); + try { + await this.#installBindingResponse(result.response, apiOrigin, kind, current); + } finally { + this.#retireCredentialSessions(apiOrigin, pcrPolicy, current); + this.#retireManagedSession(apiOrigin, pcrPolicy, result.session); + } + return; + } + if (isTerminalUserOperation(path)) { + this.#retireManagedSession(apiOrigin, pcrPolicy, result.session); + if (expected?.kind === "user") { + const current = this.#currentAuthSnapshotForManaged(expected, result.session); + this.#retireCredentialSessions(apiOrigin, pcrPolicy, current); + const cleared = clearTransportV2CredentialsIfCurrent(current); + if (cleared && path === "/protected/delete-account/confirm") { + clearTransportV2CacheRoot(apiOrigin); + } + } + return; + } + if (isTerminalPlatformOperation(path)) { + this.#retireManagedSession(apiOrigin, pcrPolicy, result.session); + if (expected?.kind === "platform") { + const current = this.#currentAuthSnapshotForManaged(expected, result.session); + this.#retireCredentialSessions(apiOrigin, pcrPolicy, current); + clearTransportV2CredentialsIfCurrent(current); + } + } + } + + async #installBindingResponse( + response: Response, + apiOrigin: string, + kind: TransportV2PrincipalKind, + expected: TransportV2AuthSnapshot + ): Promise { + const value = (await response.clone().json()) as Record; + if (typeof value.access_token !== "string" || typeof value.refresh_token !== "string") { + throw new Error("Transport v2 authentication response returned no credentials."); + } + return installTransportV2Credentials( + apiOrigin, + kind, + value.access_token, + value.refresh_token, + expected + ); + } + + #persistOAuthContinuation( + apiOrigin: string, + pcrPolicy: string, + provider: OAuthContinuation["provider"], + state: string, + session: TransportV2Session + ): void { + const continuation: OAuthContinuation = { + version: 2, + api_origin: apiOrigin, + pcr_policy: pcrPolicy, + provider, + state, + session: session.serialize() + }; + safeSessionStorageSet( + oauthContinuationKey(apiOrigin, pcrPolicy, provider), + JSON.stringify(continuation) + ); + } + + #consumeOAuthContinuation( + apiOrigin: string, + pcrPolicy: string, + provider: OAuthContinuation["provider"], + state: string + ): ManagedSession { + const key = oauthContinuationKey(apiOrigin, pcrPolicy, provider); + const raw = safeSessionStorageGet(key); + if (!raw) { + throw new Error("OAuth attested session is unavailable; restart sign-in."); + } + let continuation: OAuthContinuation; + try { + try { + continuation = parseOAuthContinuation(raw); + } catch { + safeSessionStorageRemove(key); + throw new Error("Transport v2 OAuth continuation is invalid."); + } + if ( + continuation.api_origin !== apiOrigin || + continuation.pcr_policy !== pcrPolicy || + continuation.provider !== provider || + continuation.state !== state + ) { + throw new Error("OAuth callback does not match its attested session."); + } + if (continuation.session.expiresAtUnixSeconds <= Math.floor(Date.now() / 1000)) { + safeSessionStorageRemove(key); + throw new Error("OAuth attested session expired; restart sign-in."); + } + // Consume before network transmission. A callback is never transplanted + // or retried under a replacement session. + safeSessionStorageRemove(key); + return { session: TransportV2Session.restore(continuation.session), authority: "anonymous" }; + } catch (error) { + if (error instanceof Error) throw error; + throw new Error("Transport v2 OAuth continuation is invalid."); + } + } + + #clearOAuthContinuations(apiOrigin: string): void { + const storage = sessionStorageOrUndefined(); + if (!storage) return; + const keys: string[] = []; + try { + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (!key?.startsWith(OAUTH_CONTINUATION_PREFIX)) continue; + const raw = storage.getItem(key); + if (raw && raw.includes(`\"api_origin\":\"${apiOrigin}\"`)) keys.push(key); + } + } catch { + return; + } + for (const key of keys) safeSessionStorageRemove(key); + } + + #retireCredentialSessions( + apiOrigin: string, + pcrPolicy: string, + expected: TransportV2AuthSnapshot + ): void { + for (const [key, managed] of this.#sessions) { + if (!key.startsWith(`${apiOrigin}\n${pcrPolicy}\n`)) continue; + if ( + managed.authority !== expected.kind || + managed.principalId !== expected.principalId || + managed.authGeneration !== expected.generation + ) { + continue; + } + managed.session.dispose(); + this.#sessions.delete(key); + } + } + + #removeManagedSessionReference(key: string, expected: ManagedSession): void { + if (this.#sessions.get(key) === expected) this.#sessions.delete(key); + } + + #isExpectedOrManagedAuthCurrent( + expected: TransportV2AuthSnapshot, + managed: ManagedSession + ): boolean { + if (isTransportV2AuthSnapshotCurrent(expected)) return true; + if ( + managed.authority !== expected.kind || + managed.principalId !== expected.principalId || + managed.authGeneration === undefined + ) { + return false; + } + return isTransportV2AuthSnapshotCurrent({ + apiOrigin: expected.apiOrigin, + kind: expected.kind, + principalId: expected.principalId, + generation: managed.authGeneration + }); + } + + #currentAuthSnapshotForManaged( + expected: TransportV2AuthSnapshot, + managed: ManagedSession + ): TransportV2AuthSnapshot { + if (isTransportV2AuthSnapshotCurrent(expected)) return expected; + if ( + managed.authority !== expected.kind || + managed.principalId !== expected.principalId || + managed.authGeneration === undefined + ) { + throw new TransportV2AuthorityChangedError(); + } + const refreshed = { + apiOrigin: expected.apiOrigin, + kind: expected.kind, + principalId: expected.principalId, + generation: managed.authGeneration + }; + if (!isTransportV2AuthSnapshotCurrent(refreshed)) { + throw new TransportV2AuthorityChangedError(); + } + return refreshed; + } + + #retireManagedSession(apiOrigin: string, pcrPolicy: string, failed: ManagedSession): void { + failed.session.dispose(); + const prefix = `${apiOrigin}\n${pcrPolicy}\n`; + for (const [key, current] of this.#sessions) { + if (key.startsWith(prefix) && current === failed) this.#sessions.delete(key); + } + } + + #slot(apiOrigin: string, pcrPolicy: string, label: string): string { + return `${apiOrigin}\n${pcrPolicy}\n${label}`; + } + + #authSnapshotKey(snapshot: TransportV2AuthSnapshot): string { + return `${snapshot.apiOrigin}\n${snapshot.kind}\n${snapshot.principalId ?? ""}\n${snapshot.generation}`; + } +} + +export const transportV2Client = new TransportV2Client(); diff --git a/sdk/src/lib/transportV2/envelope.ts b/sdk/src/lib/transportV2/envelope.ts index 546352b16..b2f1c9f72 100644 --- a/sdk/src/lib/transportV2/envelope.ts +++ b/sdk/src/lib/transportV2/envelope.ts @@ -15,8 +15,10 @@ const KIB = 1024; const MIB = KIB * KIB; export const TRANSPORT_V2_LIMITS = Object.freeze({ - envelopeBytes: 50 * MIB, - logicalBodyBytes: 28 * MIB, + requestEnvelopeBytes: 67 * MIB, + requestLogicalBodyBytes: 50 * MIB, + responseEnvelopeBytes: 50 * MIB, + responseLogicalBodyBytes: 28 * MIB, pathBytes: 4096, queryBytes: 8192, headerCount: 64, @@ -328,7 +330,7 @@ export function serializeRequestEnvelope(envelope: TransportV2RequestEnvelope): const headers = validateAndEncodeHeaders(envelope.request.headers); if ( envelope.request.body && - envelope.request.body.length > TRANSPORT_V2_LIMITS.logicalBodyBytes + envelope.request.body.length > TRANSPORT_V2_LIMITS.requestLogicalBodyBytes ) { throw new TransportV2ProtocolError("Transport v2 body exceeds its size limit."); } @@ -368,14 +370,14 @@ export function serializeRequestEnvelope(envelope: TransportV2RequestEnvelope): } }; const bytes = encodeUtf8(JSON.stringify(wire)); - if (bytes.length > TRANSPORT_V2_LIMITS.envelopeBytes) { + if (bytes.length > TRANSPORT_V2_LIMITS.requestEnvelopeBytes) { throw new TransportV2ProtocolError("Transport v2 envelope exceeds its size limit."); } return bytes; } export function parseUnaryResponseEnvelope(plaintext: Uint8Array): TransportV2UnaryResponse { - if (plaintext.length > TRANSPORT_V2_LIMITS.envelopeBytes) { + if (plaintext.length > TRANSPORT_V2_LIMITS.responseEnvelopeBytes) { throw new TransportV2ProtocolError("Transport v2 response exceeds its size limit."); } const value = requireExactObject( @@ -388,12 +390,12 @@ export function parseUnaryResponseEnvelope(plaintext: Uint8Array): TransportV2Un requestId: requireRequestId(value.request_id), status: requireStatus(value.status, 100, 599), headers: parseHeaders(value.headers), - body: parseBody(value.body_base64, TRANSPORT_V2_LIMITS.logicalBodyBytes, true) + body: parseBody(value.body_base64, TRANSPORT_V2_LIMITS.responseLogicalBodyBytes, true) }; } export function parseStreamRecord(plaintext: Uint8Array): TransportV2StreamRecord { - if (plaintext.length > TRANSPORT_V2_LIMITS.envelopeBytes) { + if (plaintext.length > TRANSPORT_V2_LIMITS.responseEnvelopeBytes) { throw new TransportV2ProtocolError("Transport v2 stream record exceeds its size limit."); } const parsed = parseStrictJson(decodeUtf8(plaintext)); diff --git a/sdk/src/lib/transportV2/handshake.ts b/sdk/src/lib/transportV2/handshake.ts index bb1689dab..72d02115d 100644 --- a/sdk/src/lib/transportV2/handshake.ts +++ b/sdk/src/lib/transportV2/handshake.ts @@ -69,7 +69,8 @@ export class TransportV2Handshake { async complete( attestedServerPublicKey: Uint8Array, keyExchangeResponseBody: string, - subtle: SubtleCrypto = globalThis.crypto.subtle + subtle: SubtleCrypto = globalThis.crypto.subtle, + responseRecordLimit?: number ): Promise { if (this.#used) { throw new TransportV2ProtocolError("Transport v2 handshake is already consumed."); @@ -114,7 +115,7 @@ export class TransportV2Handshake { subtle ); try { - return new TransportV2Session(handshake); + return new TransportV2Session(handshake, responseRecordLimit); } finally { handshake.requestKey.fill(0); handshake.responseKey.fill(0); diff --git a/sdk/src/lib/transportV2/index.ts b/sdk/src/lib/transportV2/index.ts index f6ad8d1e0..3dfe2bbba 100644 --- a/sdk/src/lib/transportV2/index.ts +++ b/sdk/src/lib/transportV2/index.ts @@ -34,6 +34,7 @@ export { PreparedTransportV2Request, TransportV2Session, type PrepareTransportV2Request, + type SerializedTransportV2SessionState, type TransportV2HttpRequest } from "./session"; export { TransportV2Handshake, type TransportV2KeyExchangeRequest } from "./handshake"; diff --git a/sdk/src/lib/transportV2/publicAuth.ts b/sdk/src/lib/transportV2/publicAuth.ts new file mode 100644 index 000000000..e1864c1df --- /dev/null +++ b/sdk/src/lib/transportV2/publicAuth.ts @@ -0,0 +1,33 @@ +import type { TransportV2SessionInfo } from "./client"; +import { transportV2Client } from "./client"; +import { + commitTransportV2AuthBundleImport, + exportTransportV2AuthBundle as exportStoredBundle, + prepareTransportV2AuthBundleImport, + snapshotTransportV2Auth +} from "./auth"; + +/** + * Exports the current user resumption descriptors and stable cache namespace + * root as a portable, origin-bound Transport V2 bundle for trusted local IPC. + * The result is encoded, not encrypted, and must never be placed in a URL, + * log, clipboard, or other untrusted transport. + */ +export async function exportTransportV2AuthBundle(apiUrl: string): Promise { + return exportStoredBundle(apiUrl); +} + +/** + * Installs a sensitive Transport V2 user auth bundle received through trusted + * local IPC for the exact configured API URL. The imported resumption + * credential remains authoritative; no client-provided user identifier is + * trusted. + */ +export async function importTransportV2AuthBundle(bundle: string, apiUrl: string): Promise { + const expected = snapshotTransportV2Auth(apiUrl, "user"); + const prepared = prepareTransportV2AuthBundleImport(bundle, apiUrl); + transportV2Client.retireAuthenticationState(prepared.apiOrigin, "user"); + commitTransportV2AuthBundleImport(prepared, expected); +} + +export type { TransportV2SessionInfo }; diff --git a/sdk/src/lib/transportV2/session.ts b/sdk/src/lib/transportV2/session.ts index 0a2fba32f..c324093e5 100644 --- a/sdk/src/lib/transportV2/session.ts +++ b/sdk/src/lib/transportV2/session.ts @@ -3,10 +3,7 @@ import { TransportV2ProtocolError, decodeCanonicalBase64, encodeCanonicalBase64, - encodeUtf8, generateRequestId, - parseStrictJson, - requireExactObject, uuidToBytes } from "./encoding"; import { @@ -29,11 +26,17 @@ import { TransportV2StreamDecoder } from "./stream"; const MAX_REQUEST_RECORDS = 65_536; const MAX_RESPONSE_RECORDS = 65_536; -const OUTER_RESPONSE_OVERHEAD_BYTES = 32; const MAX_OUTER_RESPONSE_BODY_BYTES = - Math.ceil((TRANSPORT_V2_LIMITS.envelopeBytes + MIN_ENCRYPTED_RECORD_BYTES) / 3) * 4 + - OUTER_RESPONSE_OVERHEAD_BYTES; -const MAX_OUTER_REQUEST_BODY_BYTES = 50 * 1024 * 1024; + TRANSPORT_V2_LIMITS.responseEnvelopeBytes + MIN_ENCRYPTED_RECORD_BYTES; +const MAX_OUTER_REQUEST_BODY_BYTES = + TRANSPORT_V2_LIMITS.requestEnvelopeBytes + MIN_ENCRYPTED_RECORD_BYTES; + +export class TransportV2SessionUnavailableError extends TransportV2ProtocolError { + constructor(message: string) { + super(message); + this.name = "TransportV2SessionUnavailableError"; + } +} export interface PrepareTransportV2Request extends Omit {} @@ -41,7 +44,22 @@ export interface TransportV2HttpRequest { path: "/v2/request"; method: "POST"; headers: Readonly>; - body: string; + body: Uint8Array; +} + +/** + * Versioned client-side state used only to continue the exact anonymous + * session across an OAuth redirect. Bound sessions are resumed with their + * encrypted resumption credential instead of persisting directional keys. + */ +export interface SerializedTransportV2SessionState { + version: 2; + sessionId: string; + expiresAtUnixSeconds: number; + requestKeyBase64: string; + responseKeyBase64: string; + requestRecords: number; + responseRecords: number; } export class PreparedTransportV2Request { @@ -77,7 +95,7 @@ export class PreparedTransportV2Request { return request; } - decryptUnaryResponse(outerBody: string): TransportV2UnaryResponse { + decryptUnaryResponse(outerBody: Uint8Array): TransportV2UnaryResponse { if (this.responseMode !== "unary") { throw new TransportV2ProtocolError("Transport v2 request did not select a unary response."); } @@ -85,7 +103,7 @@ export class PreparedTransportV2Request { return this.#responseContext.decryptUnaryResponse(outerBody); } - decryptPreStartUnaryError(outerBody: string): TransportV2UnaryResponse { + decryptPreStartUnaryError(outerBody: Uint8Array): TransportV2UnaryResponse { if (this.responseMode !== "stream") { throw new TransportV2ProtocolError("Transport v2 request did not select streaming."); } @@ -136,11 +154,11 @@ class TransportV2ResponseContext { this.#releasePreStartTerminalRecord = releasePreStartTerminalRecord; } - decryptUnaryResponse(outerBody: string): TransportV2UnaryResponse { + decryptUnaryResponse(outerBody: Uint8Array): TransportV2UnaryResponse { return this.#decryptUnaryOuter(outerBody, false); } - decryptPreStartUnaryError(outerBody: string): TransportV2UnaryResponse { + decryptPreStartUnaryError(outerBody: Uint8Array): TransportV2UnaryResponse { return this.#decryptUnaryOuter(outerBody, true); } @@ -154,7 +172,7 @@ class TransportV2ResponseContext { responseKey, encrypted, streamResponseRecordAad(this.#sessionId, this.#requestId, sequence), - TRANSPORT_V2_LIMITS.envelopeBytes + TRANSPORT_V2_LIMITS.responseEnvelopeBytes ); }, undefined, @@ -172,28 +190,18 @@ class TransportV2ResponseContext { this.#responseKey = null; } - #decryptUnaryOuter(outerBody: string, requireError: boolean): TransportV2UnaryResponse { + #decryptUnaryOuter(outerBody: Uint8Array, requireError: boolean): TransportV2UnaryResponse { const responseKey = this.#takeResponseKey(); - let encrypted: Uint8Array | undefined; let plaintext: Uint8Array | undefined; try { - if (encodeUtf8(outerBody).length > MAX_OUTER_RESPONSE_BODY_BYTES) { + if (outerBody.length > MAX_OUTER_RESPONSE_BODY_BYTES) { throw new TransportV2ProtocolError("Transport v2 outer response exceeds its size limit."); } - const outer = requireExactObject( - parseStrictJson(outerBody), - ["encrypted"], - "Transport v2 outer response" - ); - encrypted = decodeCanonicalBase64( - typeof outer.encrypted === "string" ? outer.encrypted : "", - TRANSPORT_V2_LIMITS.envelopeBytes + MIN_ENCRYPTED_RECORD_BYTES - ); plaintext = decryptTransportV2Record( responseKey, - encrypted, + outerBody, unaryResponseRecordAad(this.#sessionId, this.#requestId), - TRANSPORT_V2_LIMITS.envelopeBytes + TRANSPORT_V2_LIMITS.responseEnvelopeBytes ); const response = parseUnaryResponseEnvelope(plaintext); if (response.requestId !== this.#requestId) { @@ -209,7 +217,7 @@ class TransportV2ResponseContext { if (requireError) this.#releasePreStartTerminalRecord(); return response; } finally { - encrypted?.fill(0); + outerBody.fill(0); plaintext?.fill(0); responseKey.fill(0); } @@ -257,6 +265,59 @@ export class TransportV2Session { this.#responseRecordLimit = responseRecordLimit; } + static restore( + state: SerializedTransportV2SessionState, + responseRecordLimit = MAX_RESPONSE_RECORDS + ): TransportV2Session { + if ( + state.version !== 2 || + !Number.isSafeInteger(state.requestRecords) || + state.requestRecords < 0 || + state.requestRecords > MAX_REQUEST_RECORDS || + !Number.isSafeInteger(state.responseRecords) || + state.responseRecords < 0 || + state.responseRecords > responseRecordLimit + ) { + throw new TransportV2ProtocolError("Persisted transport v2 session state is invalid."); + } + + const requestKey = decodeCanonicalBase64(state.requestKeyBase64, 32); + const responseKey = decodeCanonicalBase64(state.responseKeyBase64, 32); + try { + if (requestKey.length !== 32 || responseKey.length !== 32) { + throw new TransportV2ProtocolError("Persisted transport v2 session key is invalid."); + } + const session = new TransportV2Session( + { + sessionId: state.sessionId, + expiresAtUnixSeconds: state.expiresAtUnixSeconds, + requestKey, + responseKey + }, + responseRecordLimit + ); + session.#requestRecords = state.requestRecords; + session.#responseRecords = state.responseRecords; + return session; + } finally { + requestKey.fill(0); + responseKey.fill(0); + } + } + + serialize(): SerializedTransportV2SessionState { + this.#requireActive(); + return { + version: 2, + sessionId: this.sessionId, + expiresAtUnixSeconds: this.expiresAtUnixSeconds, + requestKeyBase64: encodeCanonicalBase64(this.#requestKey), + responseKeyBase64: encodeCanonicalBase64(this.#responseKey), + requestRecords: this.#requestRecords, + responseRecords: this.#responseRecords + }; + } + prepareRequest( input: PrepareTransportV2Request, random: Crypto = globalThis.crypto, @@ -264,10 +325,12 @@ export class TransportV2Session { ): PreparedTransportV2Request { this.#requireActive(); if (nowUnixSeconds >= this.expiresAtUnixSeconds) { - throw new TransportV2ProtocolError("Transport v2 session has expired."); + throw new TransportV2SessionUnavailableError("Transport v2 session has expired."); } if (this.#requestRecords >= MAX_REQUEST_RECORDS) { - throw new TransportV2ProtocolError("Transport v2 request record budget is exhausted."); + throw new TransportV2SessionUnavailableError( + "Transport v2 request record budget is exhausted." + ); } const expectedResponseRecords = input.responseMode === "stream" ? 2 : 1; @@ -298,10 +361,11 @@ export class TransportV2Session { undefined, random ); - const outerBody = JSON.stringify({ encrypted: encodeCanonicalBase64(encrypted) }); - if (encodeUtf8(outerBody).length > MAX_OUTER_REQUEST_BODY_BYTES) { + if (encrypted.length > MAX_OUTER_REQUEST_BODY_BYTES) { throw new TransportV2ProtocolError("Transport v2 outer request exceeds its size limit."); } + const outerBody = encrypted; + encrypted = undefined; this.#requestRecords += 1; return new PreparedTransportV2Request( new TransportV2ResponseContext( @@ -316,7 +380,10 @@ export class TransportV2Session { { path: "/v2/request", method: "POST", - headers: { "content-type": "application/json", "x-session-id": this.sessionId }, + headers: { + "content-type": "application/octet-stream", + "x-session-id": this.sessionId + }, body: outerBody } ); @@ -353,7 +420,9 @@ export class TransportV2Session { !Number.isSafeInteger(nextResponseRecords) || nextResponseRecords > this.#responseRecordLimit ) { - throw new TransportV2ProtocolError("Transport v2 response record budget is exhausted."); + throw new TransportV2SessionUnavailableError( + "Transport v2 response record budget is exhausted." + ); } // This method contains no asynchronous boundary. The check and increment // therefore form one atomic reservation for all requests sharing this @@ -370,7 +439,7 @@ export class TransportV2Session { #requireActive(): void { if (this.#disposed) { - throw new TransportV2ProtocolError("Transport v2 session is disposed."); + throw new TransportV2SessionUnavailableError("Transport v2 session is disposed."); } } } diff --git a/sdk/src/lib/transportV2/stream.ts b/sdk/src/lib/transportV2/stream.ts index ba455630e..21720de1a 100644 --- a/sdk/src/lib/transportV2/stream.ts +++ b/sdk/src/lib/transportV2/stream.ts @@ -10,7 +10,7 @@ import { TRANSPORT_V2_LIMITS, type TransportV2StreamRecord, parseStreamRecord } // envelope limit: 64 KiB of decoded headers/chunk bytes plus JSON/base64 // framing. This ceiling bounds partial-carrier buffering before decryption. const MAX_OUTER_STREAM_FRAME_BYTES = 256 * 1024; -const MAX_LOGICAL_STREAM_BYTES = 64 * 1024 * 1024; +const MAX_LOGICAL_STREAM_BYTES = TRANSPORT_V2_LIMITS.responseLogicalBodyBytes; const FRAME_PREFIX = new TextEncoder().encode("data: "); export type DecryptStreamRecord = (encrypted: Uint8Array, sequence: number) => Uint8Array; @@ -148,7 +148,7 @@ export class TransportV2StreamDecoder { ); const encrypted = decodeCanonicalBase64( encoded, - TRANSPORT_V2_LIMITS.envelopeBytes + MIN_ENCRYPTED_RECORD_BYTES + TRANSPORT_V2_LIMITS.responseEnvelopeBytes + MIN_ENCRYPTED_RECORD_BYTES ); let plaintext: Uint8Array | undefined; try {